graphharbor 0.13.0.post35__tar.gz → 0.13.0.post36__tar.gz
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.
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/PKG-INFO +2 -2
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/pyproject.toml +2 -2
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/protocol_api.py +40 -17
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_stream_heartbeat_resilience.py +92 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/.gitignore +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/LICENSE +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/README.md +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/__init__.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/__main__.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/cli.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/core_api.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/mcp_transport.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/server.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/store_api.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/src/langhost/streaming.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_application_authorization.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_cli.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_graph_discovery.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_mcp_transport.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_official_protocol_compare.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_server_paths.py +0 -0
- {graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_thread_state_projection.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: graphharbor
|
|
3
|
-
Version: 0.13.0.
|
|
3
|
+
Version: 0.13.0.post36
|
|
4
4
|
Summary: GraphHarbor CLI for a self-hosted LangGraph Agent Server on PostgreSQL and Redis
|
|
5
5
|
Project-URL: Homepage, https://github.com/ljxpython/graphharbor
|
|
6
6
|
Project-URL: Repository, https://github.com/ljxpython/graphharbor
|
|
@@ -26,7 +26,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
26
26
|
Classifier: Typing :: Typed
|
|
27
27
|
Requires-Python: >=3.11
|
|
28
28
|
Requires-Dist: click>=8.1.7
|
|
29
|
-
Requires-Dist: graphharbor-runtime==0.13.0.
|
|
29
|
+
Requires-Dist: graphharbor-runtime==0.13.0.post36
|
|
30
30
|
Requires-Dist: langgraph-cli<0.5,>=0.4.0
|
|
31
31
|
Requires-Dist: mcp<2,>=1.23
|
|
32
32
|
Requires-Dist: pyfiglet>=1.0.0
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "graphharbor"
|
|
3
3
|
# Lockstep with graphharbor-runtime; independent of langgraph-api releases.
|
|
4
|
-
version = "0.13.0.
|
|
4
|
+
version = "0.13.0.post36"
|
|
5
5
|
description = "GraphHarbor CLI for a self-hosted LangGraph Agent Server on PostgreSQL and Redis"
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
license = "MIT"
|
|
@@ -50,7 +50,7 @@ dependencies = [
|
|
|
50
50
|
"mcp>=1.23,<2",
|
|
51
51
|
"uvicorn[standard]>=0.51.0",
|
|
52
52
|
# Exact same version as graphharbor-runtime (lockstep releases).
|
|
53
|
-
"graphharbor-runtime==0.13.0.
|
|
53
|
+
"graphharbor-runtime==0.13.0.post36",
|
|
54
54
|
]
|
|
55
55
|
|
|
56
56
|
[project.urls]
|
|
@@ -264,10 +264,15 @@ async def _load_protocol_events(thread_id: UUID, since: int) -> tuple[int, list[
|
|
|
264
264
|
|
|
265
265
|
|
|
266
266
|
def _wire_matches(wire: dict[str, Any], body: dict[str, Any]) -> bool:
|
|
267
|
-
|
|
268
|
-
|
|
267
|
+
if not isinstance(wire, dict):
|
|
268
|
+
return False
|
|
269
|
+
method = str(wire.get("method") or "")
|
|
270
|
+
params = wire.get("params")
|
|
271
|
+
params_dict = params if isinstance(params, dict) else {}
|
|
272
|
+
ns = params_dict.get("namespace")
|
|
273
|
+
ns_list = list(ns) if isinstance(ns, (list, tuple)) else []
|
|
269
274
|
return _channel_matches(method, body.get("channels")) and _namespace_matches(
|
|
270
|
-
|
|
275
|
+
ns_list, body.get("namespaces"), body.get("depth")
|
|
271
276
|
)
|
|
272
277
|
|
|
273
278
|
|
|
@@ -321,36 +326,54 @@ async def protocol_event_stream(request: Request) -> JSONResponse | StreamingRes
|
|
|
321
326
|
{"code": "cursor_expired", "detail": "cursor_expired", "recovery": "thread_snapshot"},
|
|
322
327
|
status_code=410,
|
|
323
328
|
)
|
|
324
|
-
parsed_run_ids =
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
+
parsed_run_ids: set[UUID] = set()
|
|
330
|
+
for wire in replay:
|
|
331
|
+
if isinstance(wire, dict):
|
|
332
|
+
params = wire.get("params")
|
|
333
|
+
if isinstance(params, dict):
|
|
334
|
+
uid = _safe_uuid(params.get("run_id"))
|
|
335
|
+
if uid is not None:
|
|
336
|
+
parsed_run_ids.add(uid)
|
|
329
337
|
resumable = await _resumable_run_ids(parsed_run_ids)
|
|
330
338
|
active_interrupt_ids = (
|
|
331
339
|
set(thread.interrupts.keys()) if isinstance(thread.interrupts, dict) else set()
|
|
332
340
|
)
|
|
333
341
|
|
|
334
|
-
def _is_active_protocol_event(w:
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
342
|
+
def _is_active_protocol_event(w: Any) -> bool:
|
|
343
|
+
if not isinstance(w, dict):
|
|
344
|
+
return True
|
|
345
|
+
method = str(w.get("method") or "")
|
|
346
|
+
params = w.get("params")
|
|
347
|
+
params_dict = params if isinstance(params, dict) else {}
|
|
348
|
+
data = params_dict.get("data")
|
|
349
|
+
data_dict = data if isinstance(data, dict) else {}
|
|
350
|
+
data_event = str(data_dict.get("event") or "")
|
|
351
|
+
|
|
338
352
|
is_input_req = (
|
|
339
353
|
method == "input.requested"
|
|
340
|
-
or (method == "input" and
|
|
341
|
-
or
|
|
354
|
+
or (method == "input" and data_event == "requested")
|
|
355
|
+
or data_event == "input.requested"
|
|
342
356
|
)
|
|
343
357
|
if is_input_req:
|
|
344
|
-
iid = str(
|
|
358
|
+
iid = str(data_dict.get("interrupt_id") or params_dict.get("interrupt_id") or "")
|
|
345
359
|
if iid and iid not in active_interrupt_ids:
|
|
346
360
|
return False
|
|
347
361
|
return True
|
|
348
362
|
|
|
363
|
+
def _is_resumable(wire: Any) -> bool:
|
|
364
|
+
if not isinstance(wire, dict):
|
|
365
|
+
return True
|
|
366
|
+
params = wire.get("params")
|
|
367
|
+
if isinstance(params, dict):
|
|
368
|
+
uid = _safe_uuid(params.get("run_id"))
|
|
369
|
+
if uid is not None and uid not in resumable:
|
|
370
|
+
return False
|
|
371
|
+
return True
|
|
372
|
+
|
|
349
373
|
replay = [
|
|
350
374
|
wire
|
|
351
375
|
for wire in replay
|
|
352
|
-
if (
|
|
353
|
-
and _is_active_protocol_event(wire)
|
|
376
|
+
if _is_resumable(wire) and _is_active_protocol_event(wire)
|
|
354
377
|
]
|
|
355
378
|
except Exception:
|
|
356
379
|
await manager.remove_thread_stream(thread_id, queue)
|
{graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_stream_heartbeat_resilience.py
RENAMED
|
@@ -342,3 +342,95 @@ async def test_zombie_interrupt_replay_filtered(monkeypatch):
|
|
|
342
342
|
assert "int-old" not in all_output, "Zombie interrupt 'int-old' must NOT be replayed to client!"
|
|
343
343
|
assert "int-active" in all_output, "Active interrupt 'int-active' must be replayed to client!"
|
|
344
344
|
|
|
345
|
+
|
|
346
|
+
@pytest.mark.asyncio
|
|
347
|
+
async def test_protocol_stream_replay_with_non_dict_data_payloads(monkeypatch):
|
|
348
|
+
"""Ensure protocol stream replay handles events where data is a list, string, or params is malformed."""
|
|
349
|
+
thread_id = uuid4()
|
|
350
|
+
run_id = uuid4()
|
|
351
|
+
|
|
352
|
+
mock_thread = ThreadRow(
|
|
353
|
+
thread_id=thread_id,
|
|
354
|
+
status="busy",
|
|
355
|
+
metadata_={},
|
|
356
|
+
config={},
|
|
357
|
+
values_={},
|
|
358
|
+
interrupts={},
|
|
359
|
+
error=None,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
manager = InMemoryStreamManager()
|
|
363
|
+
monkeypatch.setattr("langhost.protocol_api.get_stream_manager", lambda: manager)
|
|
364
|
+
monkeypatch.setattr(
|
|
365
|
+
"langhost.protocol_api._thread",
|
|
366
|
+
lambda *a, **k: asyncio.sleep(0, result=mock_thread),
|
|
367
|
+
)
|
|
368
|
+
monkeypatch.setattr(
|
|
369
|
+
"langhost.streaming._resumable_run_ids",
|
|
370
|
+
lambda rids: asyncio.sleep(0, result=rids),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# Various weird payloads that occur in real production (list data, none params, string data)
|
|
374
|
+
wire_list_data = {
|
|
375
|
+
"type": "event",
|
|
376
|
+
"event_id": "evt-list",
|
|
377
|
+
"seq": 1,
|
|
378
|
+
"method": "messages",
|
|
379
|
+
"params": {
|
|
380
|
+
"run_id": str(run_id),
|
|
381
|
+
"thread_id": str(thread_id),
|
|
382
|
+
"namespace": [],
|
|
383
|
+
"data": [{"delta": {"type": "block-delta", "text": "hello"}}],
|
|
384
|
+
},
|
|
385
|
+
}
|
|
386
|
+
wire_string_data = {
|
|
387
|
+
"type": "event",
|
|
388
|
+
"event_id": "evt-str",
|
|
389
|
+
"seq": 2,
|
|
390
|
+
"method": "custom",
|
|
391
|
+
"params": {
|
|
392
|
+
"run_id": str(run_id),
|
|
393
|
+
"thread_id": str(thread_id),
|
|
394
|
+
"namespace": [],
|
|
395
|
+
"data": "plain string",
|
|
396
|
+
},
|
|
397
|
+
}
|
|
398
|
+
wire_none_params = {
|
|
399
|
+
"type": "event",
|
|
400
|
+
"event_id": "evt-none-params",
|
|
401
|
+
"seq": 3,
|
|
402
|
+
"method": "custom",
|
|
403
|
+
"params": None,
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
monkeypatch.setattr(
|
|
407
|
+
"langhost.protocol_api._load_protocol_events",
|
|
408
|
+
lambda *a, **k: asyncio.sleep(
|
|
409
|
+
0, result=(0, [wire_list_data, wire_string_data, wire_none_params])
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
scope, receive = _make_scope_and_receive(
|
|
414
|
+
"POST",
|
|
415
|
+
f"/threads/{thread_id}/stream/events",
|
|
416
|
+
{"thread_id": str(thread_id)},
|
|
417
|
+
body_dict={"channels": ["messages", "custom"], "since": 0},
|
|
418
|
+
)
|
|
419
|
+
req = Request(scope, receive)
|
|
420
|
+
res = await protocol_event_stream(req)
|
|
421
|
+
assert isinstance(res, StreamingResponse)
|
|
422
|
+
|
|
423
|
+
frames: list[str] = []
|
|
424
|
+
try:
|
|
425
|
+
async for chunk in res.body_iterator:
|
|
426
|
+
frames.append(chunk)
|
|
427
|
+
if len(frames) >= 2:
|
|
428
|
+
break
|
|
429
|
+
finally:
|
|
430
|
+
await res.body_iterator.aclose()
|
|
431
|
+
|
|
432
|
+
all_output = "".join(frames)
|
|
433
|
+
assert "evt-list" in all_output
|
|
434
|
+
assert "evt-str" in all_output
|
|
435
|
+
|
|
436
|
+
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_application_authorization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_official_protocol_compare.py
RENAMED
|
File without changes
|
|
File without changes
|
{graphharbor-0.13.0.post35 → graphharbor-0.13.0.post36}/tests/test_thread_state_projection.py
RENAMED
|
File without changes
|