mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
fix: restore standalone LangGraph Studio compatibility (#4760)
* fix: restore standalone LangGraph Studio compatibility * fix: secure standalone Studio assistant ownership * fix: harden Studio provenance reconciliation * fix: repair Studio persistence before runtime startup * fix: harden standalone Studio compatibility
This commit is contained in:
parent
3a967d4f9a
commit
432c09f6b0
28
README.md
28
README.md
@ -373,6 +373,34 @@ DeerFlow runs the agent runtime inside the Gateway API. Development mode enables
|
||||
|
||||
Gateway owns `/api/langgraph/*` and translates those public LangGraph-compatible paths to its native `/api/*` routers behind nginx.
|
||||
|
||||
#### LangGraph Studio (Optional)
|
||||
|
||||
The default `make dev` topology uses DeerFlow's Gateway-embedded runtime and
|
||||
does not require LangGraph Studio. To inspect and test the registered lead-agent
|
||||
graph with the standalone development server, run the command from `backend/`
|
||||
so the CLI discovers `langgraph.json`:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run langgraph dev --allow-blocking
|
||||
```
|
||||
|
||||
The command prints the local API and Studio UI URLs. This in-memory server is
|
||||
for development and testing only. The flag permits DeerFlow's synchronous
|
||||
configuration and graph-factory setup during local Studio requests; it must not
|
||||
be treated as a production-server setting. Local Studio authentication is
|
||||
handled automatically, so the connection does not require custom headers. Use
|
||||
DeerFlow's documented production startup modes or a supported LangSmith
|
||||
deployment for production workloads. Assistant ownership and provenance in this
|
||||
standalone mode are server-owned: Studio can discover registered graphs and the
|
||||
assistants it creates, and normal assistant-version selection remains available.
|
||||
Before the locked local runtime loads its persisted development store, DeerFlow
|
||||
repairs legacy assistant rows and version history so historical client metadata
|
||||
cannot restore server privileges or be discarded by the runtime's startup
|
||||
cleanup. Keep the backend dependencies synchronized with `uv sync`; this
|
||||
compatibility path requires the declared LangGraph runtime versions and logs a
|
||||
warning if the persisted-store contract no longer matches its expectations.
|
||||
|
||||
For workflows that invoke `backend/langgraph.json` through LangGraph Studio or
|
||||
a direct LangGraph Server, DeerFlow consumes the authenticated identity
|
||||
published by that runtime and uses it for custom-agent configuration/SOUL, user
|
||||
|
||||
@ -152,10 +152,14 @@ from deerflow.config import get_app_config
|
||||
```
|
||||
|
||||
Package import hygiene: the `deerflow.agents` and `deerflow.subagents` package
|
||||
roots expose heavyweight graph/executor entrypoints lazily. Internal modules
|
||||
that only need lightweight types, config, or registries should import the
|
||||
concrete submodule instead of adding eager package-root imports that pull in the
|
||||
tool graph or subagent executor during state/schema imports.
|
||||
roots expose heavyweight graph/executor entrypoints lazily. The
|
||||
`deerflow.agents:make_lead_agent` LangGraph Server entrypoint is a concrete thin
|
||||
module-level function because the server resolves graph factories directly from
|
||||
the module dictionary; the wrapper keeps the lead-agent and skill-cache imports
|
||||
inside the function so importing the package remains lightweight. Internal
|
||||
modules that only need lightweight types, config, or registries should import
|
||||
the concrete submodule instead of adding eager package-root imports that pull in
|
||||
the tool graph or subagent executor during state/schema imports.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
|
||||
@ -278,6 +278,27 @@ backend/
|
||||
deployments run the Gateway embedded runtime; the file is kept for LangGraph
|
||||
tooling, Studio, or direct LangGraph Server compatibility.
|
||||
|
||||
To start the optional standalone development server and open its Studio URL:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run langgraph dev --allow-blocking
|
||||
```
|
||||
|
||||
Run it from `backend/` so the CLI discovers `langgraph.json`. The in-memory
|
||||
server is intended for development and testing, not production deployment. The
|
||||
flag permits DeerFlow's synchronous configuration and graph-factory setup
|
||||
during local Studio requests; it is not a production-server setting. Its local
|
||||
Studio authentication and registered graph discovery are handled automatically;
|
||||
no custom connection headers are required. Assistant ownership/provenance is
|
||||
stamped by the server, and normal assistant-version selection remains available.
|
||||
Before the locked local runtime loads its persisted development store, DeerFlow
|
||||
repairs legacy assistant rows and version history so older metadata cannot
|
||||
reactivate server-only privileges or be discarded by runtime startup cleanup.
|
||||
Run `uv sync` after dependency changes; this compatibility path requires the
|
||||
declared LangGraph runtime versions and warns when the persisted-store contract
|
||||
does not match its expectations.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
@ -8,6 +8,31 @@ Browser auth sessions are owned by `app.gateway.auth.session_cookie`. Login acce
|
||||
|
||||
Localhost persistence deliberately reads the direct request `Host` and ignores `Forwarded` / `X-Forwarded-Host`. Scheme and auth-origin reconstruction still consume forwarding headers. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header, so the outer trusted proxy must replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
|
||||
|
||||
Standalone local LangGraph Studio is recognized only through the upstream
|
||||
`Auth.types.StudioUser` principal type, never by its reusable identity string.
|
||||
The type is resolved once at import; an older SDK without it degrades to normal
|
||||
owner scoping instead of failing requests.
|
||||
For that principal's assistant reads/searches, `langgraph_auth.add_owner_filter`
|
||||
selects genuine server-registered assistants plus assistants owned by Studio;
|
||||
all other resources remain owner-scoped. Assistant create/update handlers make
|
||||
both `user_id` and `created_by=user` server-owned, because LangGraph gives
|
||||
`created_by=system` privileged ownership semantics during run creation. The
|
||||
custom application module in `langgraph_studio.py` is imported before the
|
||||
locked in-memory runtime lifespan. At that pre-runtime boundary it derives
|
||||
genuine system assistant IDs from the CLI-provided graph registry, removes
|
||||
their persisted active/version rows so graph registration recreates them, and
|
||||
demotes every other legacy `created_by=system` marker in both active assistants
|
||||
and version history. This must happen before runtime 0.30.0 loads and purges
|
||||
system-marked rows; a user application lifespan is too late. An empty graph
|
||||
registry or absent persistence file is a no-op, while persistence parse/write
|
||||
errors fail startup closed. The harness requires in-memory runtime 0.30.0 or
|
||||
newer, and a persisted store containing no expected registered assistant row
|
||||
emits a drift warning so changes to LangGraph's internal persistence contract
|
||||
are observable. Because current create/update writes and all legacy
|
||||
versions are sanitized, ordinary owner-scoped assistant version selection
|
||||
remains enabled. Ordinary authenticated users retain owner-scoped assistant
|
||||
reads/searches.
|
||||
|
||||
**Routers**:
|
||||
|
||||
| Router | Endpoints |
|
||||
|
||||
@ -25,6 +25,11 @@ from app.gateway.deps import get_local_provider
|
||||
|
||||
auth = Auth()
|
||||
|
||||
# StudioUser was added after DeerFlow's historical langgraph-sdk floor. Resolve
|
||||
# it once so older compatible SDK installs keep ordinary owner scoping instead
|
||||
# of failing every request with an AttributeError.
|
||||
_STUDIO_USER_TYPE = getattr(Auth.types, "StudioUser", None)
|
||||
|
||||
# Methods that require CSRF validation (state-changing per RFC 7231).
|
||||
_CSRF_METHODS = frozenset({"POST", "PUT", "DELETE", "PATCH"})
|
||||
|
||||
@ -109,9 +114,27 @@ async def add_owner_filter(ctx: Auth.types.AuthContext, value: dict):
|
||||
Gateway stores thread ownership as ``metadata.user_id``.
|
||||
This handler ensures LangGraph Server enforces the same isolation.
|
||||
"""
|
||||
# On create/update: stamp user_id into metadata
|
||||
# LangGraph represents its trusted local Studio principal with a dedicated
|
||||
# user type. Do not infer that privilege from its public identity string:
|
||||
# an ordinary authenticated principal may reuse the same string.
|
||||
if _STUDIO_USER_TYPE is not None and isinstance(ctx.user, _STUDIO_USER_TYPE) and ctx.resource == "assistants" and ctx.action in {"read", "search"}:
|
||||
return {
|
||||
"$or": [
|
||||
{"created_by": "system"},
|
||||
{"user_id": ctx.user.identity},
|
||||
]
|
||||
}
|
||||
|
||||
# Ownership and provenance on external assistant writes are server-owned.
|
||||
# LangGraph treats ``created_by=system`` as privileged during run creation,
|
||||
# so accepting that marker from request metadata would cross the auth
|
||||
# boundary. The standalone pre-runtime persistence repair also scrubs this
|
||||
# marker from legacy active rows and their version history before normal
|
||||
# version selection becomes available.
|
||||
metadata = value.setdefault("metadata", {})
|
||||
metadata["user_id"] = ctx.user.identity
|
||||
if ctx.resource == "assistants" and ctx.action in {"create", "update"}:
|
||||
metadata["created_by"] = "user"
|
||||
|
||||
# Return filter dict — LangGraph applies it to search/read/delete
|
||||
return {"user_id": ctx.user.identity}
|
||||
|
||||
185
backend/app/gateway/langgraph_studio.py
Normal file
185
backend/app/gateway/langgraph_studio.py
Normal file
@ -0,0 +1,185 @@
|
||||
"""Pre-runtime persistence repair for standalone LangGraph Studio.
|
||||
|
||||
``langgraph dev`` imports this custom application before entering the locked
|
||||
in-memory runtime lifespan. That ordering is intentional: runtime 0.30.0 loads
|
||||
and purges persisted ``created_by=system`` assistants before graph registration
|
||||
and before a user application lifespan can run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Collection, MutableMapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_OPS_PATH = Path(".langgraph_api/.langgraph_ops.pckl")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProvenanceRepair:
|
||||
"""Summary of one atomic pre-runtime persistence repair."""
|
||||
|
||||
removed_registered_assistants: int = 0
|
||||
removed_registered_versions: int = 0
|
||||
demoted_assistants: int = 0
|
||||
demoted_versions: int = 0
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return any(
|
||||
(
|
||||
self.removed_registered_assistants,
|
||||
self.removed_registered_versions,
|
||||
self.demoted_assistants,
|
||||
self.demoted_versions,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def configured_system_assistant_ids(
|
||||
graphs_json: str | None,
|
||||
*,
|
||||
namespace: UUID | None = None,
|
||||
) -> set[str]:
|
||||
"""Derive registered assistant IDs from the CLI-provided graph registry."""
|
||||
if not graphs_json:
|
||||
return set()
|
||||
|
||||
graphs = json.loads(graphs_json)
|
||||
if not isinstance(graphs, dict):
|
||||
raise ValueError("LANGSERVE_GRAPHS must contain a JSON object")
|
||||
if not graphs:
|
||||
return set()
|
||||
|
||||
if namespace is None:
|
||||
from langgraph_api.graph import NAMESPACE_GRAPH
|
||||
|
||||
namespace = NAMESPACE_GRAPH
|
||||
|
||||
return {str(uuid5(namespace, str(graph_id))) for graph_id in graphs}
|
||||
|
||||
|
||||
def _demote_system_marker(row: MutableMapping[str, Any]) -> tuple[MutableMapping[str, Any], bool]:
|
||||
metadata = row.get("metadata") or {}
|
||||
if metadata.get("created_by") != "system":
|
||||
return row, False
|
||||
|
||||
repaired = dict(row)
|
||||
repaired_metadata = dict(metadata)
|
||||
repaired_metadata["created_by"] = "user"
|
||||
repaired["metadata"] = repaired_metadata
|
||||
return repaired, True
|
||||
|
||||
|
||||
def repair_persisted_assistant_provenance(
|
||||
store: MutableMapping[str, Any],
|
||||
*,
|
||||
registered_system_ids: Collection[str],
|
||||
) -> ProvenanceRepair:
|
||||
"""Repair legacy assistant rows before the in-memory runtime loads them.
|
||||
|
||||
Configured graph assistant IDs are removed so graph registration recreates
|
||||
them with server-owned provenance. Every other legacy system marker is
|
||||
demoted in both the active row and its version history. The replacement
|
||||
lists are built before either store key is assigned, so a malformed row
|
||||
cannot leave a partially repaired store.
|
||||
"""
|
||||
registered_ids = {str(assistant_id) for assistant_id in registered_system_ids}
|
||||
if not registered_ids:
|
||||
return ProvenanceRepair()
|
||||
|
||||
assistants: list[MutableMapping[str, Any]] = []
|
||||
removed_registered_assistants = 0
|
||||
demoted_assistants = 0
|
||||
for row in store.get("assistants") or []:
|
||||
if str(row.get("assistant_id")) in registered_ids:
|
||||
removed_registered_assistants += 1
|
||||
continue
|
||||
repaired, demoted = _demote_system_marker(row)
|
||||
assistants.append(repaired)
|
||||
demoted_assistants += int(demoted)
|
||||
|
||||
versions: list[MutableMapping[str, Any]] = []
|
||||
removed_registered_versions = 0
|
||||
demoted_versions = 0
|
||||
for row in store.get("assistant_versions") or []:
|
||||
if str(row.get("assistant_id")) in registered_ids:
|
||||
removed_registered_versions += 1
|
||||
continue
|
||||
repaired, demoted = _demote_system_marker(row)
|
||||
versions.append(repaired)
|
||||
demoted_versions += int(demoted)
|
||||
|
||||
result = ProvenanceRepair(
|
||||
removed_registered_assistants=removed_registered_assistants,
|
||||
removed_registered_versions=removed_registered_versions,
|
||||
demoted_assistants=demoted_assistants,
|
||||
demoted_versions=demoted_versions,
|
||||
)
|
||||
store["assistants"] = assistants
|
||||
store["assistant_versions"] = versions
|
||||
return result
|
||||
|
||||
|
||||
def repair_local_dev_persistence_before_runtime(
|
||||
*,
|
||||
persistence_path: Path = _OPS_PATH,
|
||||
graphs_json: str | None = None,
|
||||
) -> ProvenanceRepair:
|
||||
"""Load, repair, and atomically rewrite a locked local-dev store."""
|
||||
if graphs_json is None:
|
||||
graphs_json = os.getenv("LANGSERVE_GRAPHS")
|
||||
registered_ids = configured_system_assistant_ids(graphs_json)
|
||||
if not registered_ids or not persistence_path.is_file():
|
||||
return ProvenanceRepair()
|
||||
|
||||
from langgraph.checkpoint.memory import PersistentDict
|
||||
|
||||
store = PersistentDict(dict, filename=str(persistence_path))
|
||||
store.load()
|
||||
result = repair_persisted_assistant_provenance(
|
||||
store,
|
||||
registered_system_ids=registered_ids,
|
||||
)
|
||||
if not (result.removed_registered_assistants or result.removed_registered_versions):
|
||||
logger.warning(
|
||||
"Standalone Studio persistence repair matched no persisted registered assistant rows; verify the LangGraph runtime persistence contract before trusting provenance repair",
|
||||
)
|
||||
if result.changed:
|
||||
store.sync()
|
||||
return result
|
||||
|
||||
|
||||
def _prepare_locked_local_dev_runtime() -> None:
|
||||
if os.getenv("LANGSMITH_LANGGRAPH_API_VARIANT") != "local_dev":
|
||||
return
|
||||
if "__inmem" not in os.getenv("MIGRATIONS_PATH", ""):
|
||||
return
|
||||
|
||||
result = repair_local_dev_persistence_before_runtime()
|
||||
if result.changed:
|
||||
logger.warning(
|
||||
"Repaired standalone Studio persistence before runtime startup: %d registered assistant(s) and %d registered version(s) reset; %d legacy assistant marker(s) and %d version marker(s) demoted",
|
||||
result.removed_registered_assistants,
|
||||
result.removed_registered_versions,
|
||||
result.demoted_assistants,
|
||||
result.demoted_versions,
|
||||
)
|
||||
|
||||
|
||||
_prepare_locked_local_dev_runtime()
|
||||
|
||||
langgraph_app = FastAPI(
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
)
|
||||
@ -4,13 +4,16 @@
|
||||
"dependencies": [
|
||||
"."
|
||||
],
|
||||
"env": ".env",
|
||||
"env": "../.env",
|
||||
"graphs": {
|
||||
"lead_agent": "deerflow.agents:make_lead_agent"
|
||||
},
|
||||
"auth": {
|
||||
"path": "./app/gateway/langgraph_auth.py:auth"
|
||||
},
|
||||
"http": {
|
||||
"app": "./app/gateway/langgraph_studio.py:langgraph_app"
|
||||
},
|
||||
"checkpointer": {
|
||||
"path": "./packages/harness/deerflow/runtime/checkpointer/async_provider.py:make_checkpointer"
|
||||
}
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .features import Next, Prev, RuntimeFeatures
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
__all__ = [
|
||||
"create_deerflow_agent",
|
||||
"RuntimeFeatures",
|
||||
@ -12,23 +17,26 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def make_lead_agent(config: "RunnableConfig") -> Any:
|
||||
"""Build the lead graph while keeping package-root imports lightweight.
|
||||
|
||||
LangGraph Server resolves configured graph factories directly from a
|
||||
module's ``__dict__``, so this entrypoint must be a concrete module-level
|
||||
function rather than a value supplied only through ``__getattr__``.
|
||||
"""
|
||||
from .lead_agent import make_lead_agent as factory
|
||||
from .lead_agent.prompt import prime_enabled_skills_cache
|
||||
|
||||
prime_enabled_skills_cache()
|
||||
return factory(config)
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "create_deerflow_agent":
|
||||
from .factory import create_deerflow_agent
|
||||
|
||||
globals()[name] = create_deerflow_agent
|
||||
return create_deerflow_agent
|
||||
if name == "make_lead_agent":
|
||||
from .lead_agent import make_lead_agent
|
||||
from .lead_agent.prompt import prime_enabled_skills_cache
|
||||
|
||||
# LangGraph resolves deerflow.agents:make_lead_agent when registering
|
||||
# the graph. Prime at that explicit entrypoint instead of at package
|
||||
# import time so lightweight submodules can be imported without pulling
|
||||
# in the whole tool/subagent graph.
|
||||
prime_enabled_skills_cache()
|
||||
globals()[name] = make_lead_agent
|
||||
return make_lead_agent
|
||||
if name in {"DeltaThreadState", "SandboxState", "ThreadState"}:
|
||||
from .thread_state import DeltaThreadState, SandboxState, ThreadState
|
||||
|
||||
|
||||
@ -28,7 +28,9 @@ dependencies = [
|
||||
"langgraph>=1.2.9,<1.3",
|
||||
"langgraph-api>=0.8.1",
|
||||
"langgraph-cli>=0.4.24",
|
||||
"langgraph-runtime-inmem>=0.28.0",
|
||||
# Standalone Studio's pre-runtime persistence repair is validated against
|
||||
# the 0.30.0 store lifecycle and must not resolve an older implementation.
|
||||
"langgraph-runtime-inmem>=0.30.0",
|
||||
"markdownify>=1.2.2",
|
||||
"markitdown[all,xlsx]>=0.0.1a2",
|
||||
"packaging>=24.2",
|
||||
|
||||
@ -177,8 +177,17 @@ class _FakeUser:
|
||||
self.display_name = identity
|
||||
|
||||
|
||||
def _make_ctx(user_id):
|
||||
return Auth.types.AuthContext(resource="threads", action="create", user=_FakeUser(user_id), permissions=[])
|
||||
def _make_ctx(user_id, *, resource="threads", action="create", user=None):
|
||||
return Auth.types.AuthContext(resource=resource, action=action, user=user or _FakeUser(user_id), permissions=[])
|
||||
|
||||
|
||||
def _studio_ctx(*, resource="assistants", action="search"):
|
||||
return _make_ctx(
|
||||
"langgraph-studio-user",
|
||||
resource=resource,
|
||||
action=action,
|
||||
user=Auth.types.StudioUser("langgraph-studio-user"),
|
||||
)
|
||||
|
||||
|
||||
def test_filter_injects_user_id():
|
||||
@ -226,6 +235,121 @@ def test_filter_with_empty_metadata():
|
||||
assert result == {"user_id": "user-z"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["read", "search"])
|
||||
def test_studio_user_assistant_discovery_includes_system_and_studio_owned_assistants(action):
|
||||
value = {}
|
||||
result = asyncio.run(add_owner_filter(_studio_ctx(action=action), value))
|
||||
|
||||
assert result == {
|
||||
"$or": [
|
||||
{"created_by": "system"},
|
||||
{"user_id": "langgraph-studio-user"},
|
||||
]
|
||||
}
|
||||
assert value == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["create", "update"])
|
||||
def test_studio_assistant_writes_stamp_server_owned_provenance(action):
|
||||
value = {"metadata": {"created_by": "system"}}
|
||||
result = asyncio.run(add_owner_filter(_studio_ctx(action=action), value))
|
||||
|
||||
assert value["metadata"] == {
|
||||
"created_by": "user",
|
||||
"user_id": "langgraph-studio-user",
|
||||
}
|
||||
assert result == {"user_id": "langgraph-studio-user"}
|
||||
|
||||
|
||||
def test_studio_user_non_assistant_operations_remain_owner_scoped():
|
||||
value = {}
|
||||
result = asyncio.run(
|
||||
add_owner_filter(
|
||||
_studio_ctx(resource="threads", action="search"),
|
||||
value,
|
||||
)
|
||||
)
|
||||
|
||||
assert value["metadata"]["user_id"] == "langgraph-studio-user"
|
||||
assert result == {"user_id": "langgraph-studio-user"}
|
||||
|
||||
|
||||
def test_identity_string_does_not_impersonate_studio_user():
|
||||
value = {}
|
||||
result = asyncio.run(
|
||||
add_owner_filter(
|
||||
_make_ctx(
|
||||
"langgraph-studio-user",
|
||||
resource="assistants",
|
||||
action="search",
|
||||
),
|
||||
value,
|
||||
)
|
||||
)
|
||||
|
||||
assert value["metadata"]["user_id"] == "langgraph-studio-user"
|
||||
assert result == {"user_id": "langgraph-studio-user"}
|
||||
|
||||
|
||||
def test_missing_studio_user_type_degrades_to_owner_scoped_behavior():
|
||||
value = {}
|
||||
with patch("app.gateway.langgraph_auth._STUDIO_USER_TYPE", None):
|
||||
result = asyncio.run(add_owner_filter(_studio_ctx(), value))
|
||||
|
||||
assert value["metadata"]["user_id"] == "langgraph-studio-user"
|
||||
assert result == {"user_id": "langgraph-studio-user"}
|
||||
|
||||
|
||||
def test_regular_user_assistant_search_remains_owner_scoped():
|
||||
value = {}
|
||||
result = asyncio.run(
|
||||
add_owner_filter(
|
||||
_make_ctx("user-a", resource="assistants", action="search"),
|
||||
value,
|
||||
)
|
||||
)
|
||||
|
||||
assert value["metadata"]["user_id"] == "user-a"
|
||||
assert result == {"user_id": "user-a"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["create", "update"])
|
||||
def test_regular_user_cannot_forge_system_assistant_provenance(action):
|
||||
value = {"metadata": {"created_by": "system", "label": "forged"}}
|
||||
result = asyncio.run(
|
||||
add_owner_filter(
|
||||
_make_ctx("user-a", resource="assistants", action=action),
|
||||
value,
|
||||
)
|
||||
)
|
||||
|
||||
assert value["metadata"] == {
|
||||
"created_by": "user",
|
||||
"label": "forged",
|
||||
"user_id": "user-a",
|
||||
}
|
||||
assert result == {"user_id": "user-a"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ctx,user_id",
|
||||
[
|
||||
(_studio_ctx(action="update"), "langgraph-studio-user"),
|
||||
(_make_ctx("user-a", resource="assistants", action="update"), "user-a"),
|
||||
],
|
||||
)
|
||||
def test_assistant_version_selection_remains_owner_scoped(ctx, user_id):
|
||||
value = {"assistant_id": uuid4(), "version": 1}
|
||||
|
||||
result = asyncio.run(add_owner_filter(ctx, value))
|
||||
|
||||
assert value["metadata"] == {
|
||||
"created_by": "user",
|
||||
"user_id": user_id,
|
||||
}
|
||||
assert result == {"user_id": user_id}
|
||||
|
||||
|
||||
# ── Gateway parity ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
67
backend/tests/test_langgraph_graph_entrypoint.py
Normal file
67
backend/tests/test_langgraph_graph_entrypoint.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""Regression coverage for the standalone LangGraph graph entrypoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_langgraph_config_loads_the_repository_environment_file():
|
||||
"""Standalone Studio should reuse the root environment used by DeerFlow."""
|
||||
import json
|
||||
|
||||
config = json.loads((BACKEND_DIR / "langgraph.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert (BACKEND_DIR / config["env"]).resolve() == BACKEND_DIR.parent / ".env"
|
||||
|
||||
|
||||
def test_langgraph_graph_factory_is_a_concrete_lazy_module_export():
|
||||
"""Match LangGraph Server's direct ``module.__dict__`` entrypoint lookup."""
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
config = json.loads(Path("langgraph.json").read_text(encoding="utf-8"))
|
||||
module_name, variable_name = config["graphs"]["lead_agent"].split(":", 1)
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
factory = module.__dict__.get(variable_name)
|
||||
assert callable(factory), (
|
||||
f"{module_name}:{variable_name} must be a concrete module export "
|
||||
"for LangGraph Server"
|
||||
)
|
||||
assert "deerflow.agents.lead_agent" not in sys.modules, (
|
||||
"publishing the graph factory must keep heavyweight agent imports lazy"
|
||||
)
|
||||
|
||||
calls = []
|
||||
fake_lead_agent = ModuleType("deerflow.agents.lead_agent")
|
||||
fake_lead_agent.__path__ = []
|
||||
fake_lead_agent.make_lead_agent = lambda config: calls.append(("factory", config)) or "graph"
|
||||
fake_prompt = ModuleType("deerflow.agents.lead_agent.prompt")
|
||||
fake_prompt.prime_enabled_skills_cache = lambda: calls.append(("prime", None))
|
||||
sys.modules[fake_lead_agent.__name__] = fake_lead_agent
|
||||
sys.modules[fake_prompt.__name__] = fake_prompt
|
||||
|
||||
config_arg = {"configurable": {"thread_id": "test-thread"}}
|
||||
assert factory(config_arg) == "graph"
|
||||
assert calls == [("prime", None), ("factory", config_arg)]
|
||||
"""
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=BACKEND_DIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
200
backend/tests/test_langgraph_studio_lifespan.py
Normal file
200
backend/tests/test_langgraph_studio_lifespan.py
Normal file
@ -0,0 +1,200 @@
|
||||
"""Tests for pre-runtime standalone Studio provenance repair."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import NAMESPACE_DNS, uuid4, uuid5
|
||||
|
||||
from app.gateway.langgraph_studio import (
|
||||
configured_system_assistant_ids,
|
||||
repair_local_dev_persistence_before_runtime,
|
||||
repair_persisted_assistant_provenance,
|
||||
)
|
||||
|
||||
|
||||
def test_configured_system_assistant_ids_are_deterministic():
|
||||
namespace = uuid4()
|
||||
graphs = json.dumps(
|
||||
{
|
||||
"alpha": "./graph.py:alpha",
|
||||
"beta": "./graph.py:beta",
|
||||
}
|
||||
)
|
||||
|
||||
assert configured_system_assistant_ids(graphs, namespace=namespace) == {
|
||||
str(uuid5(namespace, "alpha")),
|
||||
str(uuid5(namespace, "beta")),
|
||||
}
|
||||
|
||||
|
||||
def test_configured_system_assistant_ids_skip_missing_or_empty_registry():
|
||||
assert configured_system_assistant_ids(None, namespace=NAMESPACE_DNS) == set()
|
||||
assert configured_system_assistant_ids("{}", namespace=NAMESPACE_DNS) == set()
|
||||
|
||||
|
||||
def test_pre_runtime_repair_preserves_all_legacy_user_rows_and_versions():
|
||||
system_id = str(uuid4())
|
||||
forged_ids = [str(uuid4()) for _ in range(4)]
|
||||
ordinary_id = str(uuid4())
|
||||
store = {
|
||||
"assistants": [
|
||||
{
|
||||
"assistant_id": system_id,
|
||||
"metadata": {"created_by": "system"},
|
||||
},
|
||||
*[
|
||||
{
|
||||
"assistant_id": assistant_id,
|
||||
"metadata": {
|
||||
"created_by": "system",
|
||||
"user_id": "langgraph-studio-user",
|
||||
},
|
||||
}
|
||||
for assistant_id in forged_ids
|
||||
],
|
||||
{
|
||||
"assistant_id": ordinary_id,
|
||||
"metadata": {"created_by": "user", "user_id": "owner"},
|
||||
},
|
||||
],
|
||||
"assistant_versions": [
|
||||
{
|
||||
"assistant_id": system_id,
|
||||
"version": 1,
|
||||
"metadata": {"created_by": "system"},
|
||||
},
|
||||
*[
|
||||
{
|
||||
"assistant_id": assistant_id,
|
||||
"version": version,
|
||||
"metadata": {
|
||||
"created_by": "system",
|
||||
"user_id": "langgraph-studio-user",
|
||||
},
|
||||
}
|
||||
for assistant_id in forged_ids
|
||||
for version in (1, 2)
|
||||
],
|
||||
{
|
||||
"assistant_id": ordinary_id,
|
||||
"version": 1,
|
||||
"metadata": {"created_by": "user", "user_id": "owner"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = repair_persisted_assistant_provenance(
|
||||
store,
|
||||
registered_system_ids={system_id},
|
||||
)
|
||||
|
||||
assert result.removed_registered_assistants == 1
|
||||
assert result.removed_registered_versions == 1
|
||||
assert result.demoted_assistants == 4
|
||||
assert result.demoted_versions == 8
|
||||
assert [str(row["assistant_id"]) for row in store["assistants"]] == [*forged_ids, ordinary_id]
|
||||
assert len(store["assistant_versions"]) == 9
|
||||
assert all(row["metadata"]["created_by"] == "user" for row in store["assistants"])
|
||||
assert all(row["metadata"]["created_by"] == "user" for row in store["assistant_versions"])
|
||||
|
||||
|
||||
def test_pre_runtime_repair_replaces_registered_id_even_with_forged_metadata():
|
||||
system_id = str(uuid4())
|
||||
store = {
|
||||
"assistants": [
|
||||
{
|
||||
"assistant_id": system_id,
|
||||
"metadata": {
|
||||
"created_by": "user",
|
||||
"user_id": "attacker",
|
||||
},
|
||||
}
|
||||
],
|
||||
"assistant_versions": [
|
||||
{
|
||||
"assistant_id": system_id,
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"created_by": "user",
|
||||
"user_id": "attacker",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
result = repair_persisted_assistant_provenance(
|
||||
store,
|
||||
registered_system_ids={system_id},
|
||||
)
|
||||
|
||||
assert result.removed_registered_assistants == 1
|
||||
assert result.removed_registered_versions == 1
|
||||
assert store == {"assistants": [], "assistant_versions": []}
|
||||
|
||||
|
||||
def test_pre_runtime_repair_skips_when_no_system_assistants_are_configured():
|
||||
marked_id = str(uuid4())
|
||||
store = {
|
||||
"assistants": [
|
||||
{
|
||||
"assistant_id": marked_id,
|
||||
"metadata": {"created_by": "system"},
|
||||
}
|
||||
],
|
||||
"assistant_versions": [],
|
||||
}
|
||||
original = {
|
||||
"assistants": [dict(store["assistants"][0])],
|
||||
"assistant_versions": [],
|
||||
}
|
||||
|
||||
result = repair_persisted_assistant_provenance(
|
||||
store,
|
||||
registered_system_ids=set(),
|
||||
)
|
||||
|
||||
assert not result.changed
|
||||
assert store == original
|
||||
|
||||
|
||||
def test_pre_runtime_repair_warns_when_registered_ids_match_no_persisted_rows(
|
||||
tmp_path: Path,
|
||||
caplog,
|
||||
monkeypatch,
|
||||
):
|
||||
from langgraph.checkpoint.memory import PersistentDict
|
||||
|
||||
persistence_path = tmp_path / ".langgraph_ops.pckl"
|
||||
store = PersistentDict(dict, filename=str(persistence_path))
|
||||
store["assistants"] = [
|
||||
{
|
||||
"assistant_id": str(uuid4()),
|
||||
"metadata": {
|
||||
"created_by": "system",
|
||||
"user_id": "langgraph-studio-user",
|
||||
},
|
||||
}
|
||||
]
|
||||
store["assistant_versions"] = []
|
||||
store.sync()
|
||||
monkeypatch.setattr(
|
||||
"app.gateway.langgraph_studio.configured_system_assistant_ids",
|
||||
lambda _graphs_json: {"registered-assistant-id"},
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="app.gateway.langgraph_studio"):
|
||||
result = repair_local_dev_persistence_before_runtime(
|
||||
persistence_path=persistence_path,
|
||||
graphs_json=json.dumps({"registered_graph": "./graph.py:graph"}),
|
||||
)
|
||||
|
||||
assert result.demoted_assistants == 1
|
||||
assert "matched no persisted registered assistant rows" in caplog.text
|
||||
|
||||
|
||||
def test_langgraph_config_loads_the_pre_runtime_studio_app():
|
||||
config = json.loads((Path(__file__).resolve().parents[1] / "langgraph.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert config["http"]["app"].endswith("app/gateway/langgraph_studio.py:langgraph_app")
|
||||
379
backend/tests/test_langgraph_studio_routes.py
Normal file
379
backend/tests/test_langgraph_studio_routes.py
Normal file
@ -0,0 +1,379 @@
|
||||
"""Route-level regressions for standalone LangGraph Studio assistants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
_GRAPH_SOURCE = """
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
|
||||
builder = StateGraph(dict)
|
||||
builder.add_node("noop", lambda state: {})
|
||||
builder.add_edge(START, "noop")
|
||||
builder.add_edge("noop", END)
|
||||
graph = builder.compile()
|
||||
""".lstrip()
|
||||
|
||||
_CURRENT_AUTH_SHIM = """
|
||||
from app.gateway.langgraph_auth import auth
|
||||
from app.gateway.langgraph_studio import langgraph_app
|
||||
""".lstrip()
|
||||
|
||||
_LEGACY_AUTH_SHIM = """
|
||||
from fastapi import FastAPI
|
||||
from langgraph_sdk import Auth
|
||||
|
||||
auth = Auth()
|
||||
|
||||
@auth.authenticate
|
||||
async def authenticate(request):
|
||||
return "langgraph-studio-user"
|
||||
|
||||
@auth.on
|
||||
async def legacy_owner_filter(ctx, value):
|
||||
metadata = value.setdefault("metadata", {})
|
||||
metadata["user_id"] = ctx.user.identity
|
||||
return {"user_id": ctx.user.identity}
|
||||
|
||||
langgraph_app = FastAPI()
|
||||
""".lstrip()
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _running_studio_server(
|
||||
runtime_dir: Path,
|
||||
*,
|
||||
auth_source: str,
|
||||
) -> Iterator[httpx.Client]:
|
||||
"""Run the locked dev server against one persistent runtime directory."""
|
||||
(runtime_dir / "graph.py").write_text(_GRAPH_SOURCE, encoding="utf-8")
|
||||
(runtime_dir / "auth_shim.py").write_text(auth_source, encoding="utf-8")
|
||||
config_path = runtime_dir / "langgraph.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": [str(BACKEND_DIR)],
|
||||
"graphs": {"test_graph": "./graph.py:graph"},
|
||||
"auth": {"path": "./auth_shim.py:auth"},
|
||||
"http": {"app": "./auth_shim.py:langgraph_app"},
|
||||
"env": {
|
||||
"AUTH_JWT_SECRET": "test-secret-key-for-langgraph-route-tests-min-32",
|
||||
"DEER_FLOW_AUTH_DISABLED": "1",
|
||||
"LANGSMITH_TRACING": "false",
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
port = _free_port()
|
||||
log_path = runtime_dir / f"server-{uuid4()}.log"
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = os.pathsep.join(filter(None, [str(BACKEND_DIR), env.get("PYTHONPATH")]))
|
||||
env["LANGSMITH_LANGGRAPH_API_VARIANT"] = "local_dev"
|
||||
executable = shutil.which(
|
||||
"langgraph",
|
||||
path=os.pathsep.join([str(Path(sys.executable).parent), os.environ.get("PATH", "")]),
|
||||
)
|
||||
if executable is None:
|
||||
pytest.fail("langgraph executable is unavailable; install the backend development dependencies before running Studio route tests")
|
||||
with log_path.open("w", encoding="utf-8") as log_file:
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
executable,
|
||||
"dev",
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--no-browser",
|
||||
"--no-reload",
|
||||
],
|
||||
cwd=runtime_dir,
|
||||
env=env,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
deadline = time.monotonic() + 45
|
||||
last_error: Exception | None = None
|
||||
while time.monotonic() < deadline and process.poll() is None:
|
||||
try:
|
||||
response = httpx.get(
|
||||
f"{base_url}/ok",
|
||||
timeout=1,
|
||||
trust_env=False,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
break
|
||||
except httpx.HTTPError as exc:
|
||||
last_error = exc
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
process.terminate()
|
||||
process.wait(timeout=10)
|
||||
pytest.fail(f"LangGraph dev server failed to start ({last_error!r}).\n{log_path.read_text(encoding='utf-8')}")
|
||||
|
||||
client = httpx.Client(
|
||||
base_url=base_url,
|
||||
headers={"x-auth-scheme": "langsmith"},
|
||||
timeout=10,
|
||||
trust_env=False,
|
||||
)
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
client.close()
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=10)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def studio_client(tmp_path_factory: pytest.TempPathFactory) -> Iterator[httpx.Client]:
|
||||
"""Run the locked dev server with a tiny graph and DeerFlow's real auth."""
|
||||
runtime_dir = tmp_path_factory.mktemp("langgraph-studio-routes")
|
||||
with _running_studio_server(
|
||||
runtime_dir,
|
||||
auth_source=_CURRENT_AUTH_SHIM,
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.mark.parametrize("requested_created_by", [None, "system"])
|
||||
def test_studio_create_then_get_and_search_assistant(
|
||||
studio_client: httpx.Client,
|
||||
requested_created_by: str | None,
|
||||
):
|
||||
"""Ordinary and forged create payloads stay Studio-owned and readable."""
|
||||
assistant_id = str(uuid4())
|
||||
label = f"route-test-{assistant_id}"
|
||||
metadata = {"label": label}
|
||||
if requested_created_by is not None:
|
||||
metadata["created_by"] = requested_created_by
|
||||
|
||||
response = studio_client.post(
|
||||
"/assistants",
|
||||
json={
|
||||
"assistant_id": assistant_id,
|
||||
"graph_id": "test_graph",
|
||||
"metadata": metadata,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
created = response.json()
|
||||
assert created["metadata"]["created_by"] == "user"
|
||||
assert created["metadata"]["user_id"] == "langgraph-studio-user"
|
||||
|
||||
response = studio_client.get(f"/assistants/{assistant_id}")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["assistant_id"] == assistant_id
|
||||
|
||||
response = studio_client.post(
|
||||
"/assistants/search",
|
||||
json={"metadata": {"label": label}},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert [item["assistant_id"] for item in response.json()] == [assistant_id]
|
||||
|
||||
|
||||
def test_studio_can_get_and_search_registered_system_assistant(
|
||||
studio_client: httpx.Client,
|
||||
):
|
||||
"""The registered graph remains discoverable alongside Studio-owned rows."""
|
||||
response = studio_client.post(
|
||||
"/assistants/search",
|
||||
json={"graph_id": "test_graph", "metadata": {"created_by": "system"}},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
registered = response.json()
|
||||
assert len(registered) == 1
|
||||
|
||||
assistant_id = registered[0]["assistant_id"]
|
||||
response = studio_client.get(f"/assistants/{assistant_id}")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["metadata"]["created_by"] == "system"
|
||||
|
||||
|
||||
def test_studio_update_cannot_forge_system_provenance(
|
||||
studio_client: httpx.Client,
|
||||
):
|
||||
assistant_id = str(uuid4())
|
||||
response = studio_client.post(
|
||||
"/assistants",
|
||||
json={"assistant_id": assistant_id, "graph_id": "test_graph"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
response = studio_client.patch(
|
||||
f"/assistants/{assistant_id}",
|
||||
json={"metadata": {"created_by": "system", "updated": True}},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["metadata"] == {
|
||||
"created_by": "user",
|
||||
"updated": True,
|
||||
"user_id": "langgraph-studio-user",
|
||||
}
|
||||
|
||||
response = studio_client.post(
|
||||
f"/assistants/{assistant_id}/latest",
|
||||
json={"version": 1},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["version"] == 1
|
||||
assert response.json()["metadata"]["created_by"] == "user"
|
||||
|
||||
response = studio_client.post(
|
||||
f"/assistants/{assistant_id}/latest",
|
||||
json={"version": 2},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["version"] == 2
|
||||
assert response.json()["metadata"]["created_by"] == "user"
|
||||
|
||||
|
||||
def test_non_studio_auth_disabled_principal_can_select_older_and_newer_versions(
|
||||
studio_client: httpx.Client,
|
||||
):
|
||||
"""Exercise non-Studio owner scoping without claiming JWT-path coverage."""
|
||||
assistant_id = str(uuid4())
|
||||
with httpx.Client(
|
||||
base_url=studio_client.base_url,
|
||||
timeout=10,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
response = client.post(
|
||||
"/assistants",
|
||||
json={"assistant_id": assistant_id, "graph_id": "test_graph"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
owner_id = response.json()["metadata"]["user_id"]
|
||||
assert owner_id != "langgraph-studio-user"
|
||||
|
||||
response = client.patch(
|
||||
f"/assistants/{assistant_id}",
|
||||
json={"metadata": {"revision": 2}},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["version"] == 2
|
||||
|
||||
for version in (1, 2):
|
||||
response = client.post(
|
||||
f"/assistants/{assistant_id}/latest",
|
||||
json={"version": version},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["version"] == version
|
||||
assert response.json()["metadata"] == {
|
||||
**({"revision": 2} if version == 2 else {}),
|
||||
"created_by": "user",
|
||||
"user_id": owner_id,
|
||||
}
|
||||
|
||||
|
||||
def test_persisted_legacy_assistants_survive_cross_version_restart(
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""Old forged rows are repaired before the locked runtime can purge them."""
|
||||
assistant_ids = [str(uuid4()) for _ in range(4)]
|
||||
|
||||
with _running_studio_server(
|
||||
tmp_path,
|
||||
auth_source=_LEGACY_AUTH_SHIM,
|
||||
) as legacy_client:
|
||||
for assistant_id in assistant_ids:
|
||||
response = legacy_client.post(
|
||||
"/assistants",
|
||||
json={
|
||||
"assistant_id": assistant_id,
|
||||
"graph_id": "test_graph",
|
||||
"metadata": {
|
||||
"created_by": "system",
|
||||
"legacy": assistant_id,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["metadata"]["created_by"] == "system"
|
||||
|
||||
response = legacy_client.patch(
|
||||
f"/assistants/{assistant_id}",
|
||||
json={
|
||||
"metadata": {
|
||||
"created_by": "system",
|
||||
"legacy": assistant_id,
|
||||
"revision": 2,
|
||||
}
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["version"] == 2
|
||||
|
||||
assert (tmp_path / ".langgraph_api" / ".langgraph_ops.pckl").is_file()
|
||||
|
||||
with _running_studio_server(
|
||||
tmp_path,
|
||||
auth_source=_CURRENT_AUTH_SHIM,
|
||||
) as repaired_client:
|
||||
for assistant_id in assistant_ids:
|
||||
response = repaired_client.get(f"/assistants/{assistant_id}")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["metadata"] == {
|
||||
"created_by": "user",
|
||||
"legacy": assistant_id,
|
||||
"revision": 2,
|
||||
"user_id": "langgraph-studio-user",
|
||||
}
|
||||
|
||||
response = repaired_client.post(
|
||||
f"/assistants/{assistant_id}/versions",
|
||||
json={"limit": 10},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
versions = response.json()
|
||||
assert {item["version"] for item in versions} == {1, 2}
|
||||
assert all(item["metadata"]["created_by"] == "user" for item in versions)
|
||||
|
||||
response = repaired_client.post(
|
||||
f"/assistants/{assistant_id}/latest",
|
||||
json={"version": 1},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["metadata"]["created_by"] == "user"
|
||||
|
||||
response = repaired_client.post(
|
||||
f"/assistants/{assistant_id}/latest",
|
||||
json={"version": 2},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["metadata"]["created_by"] == "user"
|
||||
2
backend/uv.lock
generated
2
backend/uv.lock
generated
@ -1027,7 +1027,7 @@ requires-dist = [
|
||||
{ name = "langgraph-checkpoint-postgres", marker = "extra == 'postgres'", specifier = ">=3.1.1,<3.2" },
|
||||
{ name = "langgraph-checkpoint-sqlite", specifier = ">=3.1.1,<3.2" },
|
||||
{ name = "langgraph-cli", specifier = ">=0.4.24" },
|
||||
{ name = "langgraph-runtime-inmem", specifier = ">=0.28.0" },
|
||||
{ name = "langgraph-runtime-inmem", specifier = ">=0.30.0" },
|
||||
{ name = "langgraph-sdk", specifier = ">=0.1.51" },
|
||||
{ name = "markdownify", specifier = ">=1.2.2" },
|
||||
{ name = "markitdown", extras = ["all", "xlsx"], specifier = ">=0.0.1a2" },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user