graphharbor 0.13.0.post34__tar.gz → 0.13.0.post35__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.
Files changed (22) hide show
  1. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/PKG-INFO +2 -2
  2. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/pyproject.toml +2 -2
  3. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/src/langhost/protocol_api.py +27 -1
  4. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/tests/test_stream_heartbeat_resilience.py +81 -0
  5. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/.gitignore +0 -0
  6. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/LICENSE +0 -0
  7. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/README.md +0 -0
  8. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/src/langhost/__init__.py +0 -0
  9. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/src/langhost/__main__.py +0 -0
  10. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/src/langhost/cli.py +0 -0
  11. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/src/langhost/core_api.py +0 -0
  12. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/src/langhost/mcp_transport.py +0 -0
  13. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/src/langhost/server.py +0 -0
  14. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/src/langhost/store_api.py +0 -0
  15. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/src/langhost/streaming.py +0 -0
  16. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/tests/test_application_authorization.py +0 -0
  17. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/tests/test_cli.py +0 -0
  18. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/tests/test_graph_discovery.py +0 -0
  19. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/tests/test_mcp_transport.py +0 -0
  20. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/tests/test_official_protocol_compare.py +0 -0
  21. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/tests/test_server_paths.py +0 -0
  22. {graphharbor-0.13.0.post34 → graphharbor-0.13.0.post35}/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.post34
3
+ Version: 0.13.0.post35
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.post34
29
+ Requires-Dist: graphharbor-runtime==0.13.0.post35
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.post34"
4
+ version = "0.13.0.post35"
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.post34",
53
+ "graphharbor-runtime==0.13.0.post35",
54
54
  ]
55
55
 
56
56
  [project.urls]
@@ -189,6 +189,12 @@ async def protocol_commands(request: Request) -> JSONResponse:
189
189
  key: value for key, value in persisted.interrupts.items() if key != interrupt_id
190
190
  }
191
191
  persisted.status = "busy"
192
+ old_run = await conn.session.get(RunRow, latest.run_id)
193
+ if old_run is not None:
194
+ old_kwargs = dict(old_run.kwargs or {})
195
+ if old_kwargs.get("stream_resumable") is True:
196
+ old_kwargs["stream_resumable"] = False
197
+ old_run.kwargs = old_kwargs
192
198
  return JSONResponse(
193
199
  {
194
200
  "id": command_id,
@@ -321,10 +327,30 @@ async def protocol_event_stream(request: Request) -> JSONResponse | StreamingRes
321
327
  if (uid := _safe_uuid(wire.get("params", {}).get("run_id"))) is not None
322
328
  }
323
329
  resumable = await _resumable_run_ids(parsed_run_ids)
330
+ active_interrupt_ids = (
331
+ set(thread.interrupts.keys()) if isinstance(thread.interrupts, dict) else set()
332
+ )
333
+
334
+ def _is_active_protocol_event(w: dict[str, Any]) -> bool:
335
+ method = w.get("method")
336
+ params = w.get("params") or {}
337
+ data = params.get("data") or {}
338
+ is_input_req = (
339
+ method == "input.requested"
340
+ or (method == "input" and data.get("event") == "requested")
341
+ or data.get("event") == "input.requested"
342
+ )
343
+ if is_input_req:
344
+ iid = str(data.get("interrupt_id") or params.get("interrupt_id") or "")
345
+ if iid and iid not in active_interrupt_ids:
346
+ return False
347
+ return True
348
+
324
349
  replay = [
325
350
  wire
326
351
  for wire in replay
327
- if (uid := _safe_uuid(wire.get("params", {}).get("run_id"))) is None or uid in resumable
352
+ if ((uid := _safe_uuid(wire.get("params", {}).get("run_id"))) is None or uid in resumable)
353
+ and _is_active_protocol_event(wire)
328
354
  ]
329
355
  except Exception:
330
356
  await manager.remove_thread_stream(thread_id, queue)
@@ -261,3 +261,84 @@ async def test_stream_response_headers_compliance(monkeypatch):
261
261
  assert "no-cache" in res_thread.headers["cache-control"]
262
262
  assert res_thread.headers["x-accel-buffering"] == "no"
263
263
  await res_thread.body_iterator.aclose()
264
+
265
+
266
+ @pytest.mark.asyncio
267
+ async def test_zombie_interrupt_replay_filtered(monkeypatch):
268
+ """Ensure resolved/historical input.requested events are filtered from replay."""
269
+ thread_id = uuid4()
270
+ run_id = uuid4()
271
+
272
+ # Active interrupts on thread only contains 'int-active'
273
+ mock_thread = ThreadRow(
274
+ thread_id=thread_id,
275
+ status="interrupted",
276
+ metadata_={},
277
+ config={},
278
+ values_={},
279
+ interrupts={"int-active": {"id": "int-active", "value": "Approve step 2"}},
280
+ error=None,
281
+ )
282
+
283
+ manager = InMemoryStreamManager()
284
+ monkeypatch.setattr("langhost.protocol_api.get_stream_manager", lambda: manager)
285
+ monkeypatch.setattr(
286
+ "langhost.protocol_api._thread",
287
+ lambda *a, **k: asyncio.sleep(0, result=mock_thread),
288
+ )
289
+ monkeypatch.setattr(
290
+ "langhost.streaming._resumable_run_ids",
291
+ lambda run_ids: asyncio.sleep(0, result=set(run_ids)),
292
+ )
293
+
294
+ # 2 historical events: seq 1 is zombie (int-old), seq 2 is active (int-active)
295
+ wire_old = protocol_event(
296
+ event_id="evt-1",
297
+ sequence=1,
298
+ run_id=str(run_id),
299
+ thread_id=str(thread_id),
300
+ event={
301
+ "event": "input.requested",
302
+ "data": {"interrupt_id": "int-old", "value": "Old resolved request"},
303
+ },
304
+ )
305
+ wire_active = protocol_event(
306
+ event_id="evt-2",
307
+ sequence=2,
308
+ run_id=str(run_id),
309
+ thread_id=str(thread_id),
310
+ event={
311
+ "event": "input.requested",
312
+ "data": {"interrupt_id": "int-active", "value": "Active request"},
313
+ },
314
+ )
315
+
316
+ monkeypatch.setattr(
317
+ "langhost.protocol_api._load_protocol_events",
318
+ lambda *a, **k: asyncio.sleep(0, result=(0, [wire_old, wire_active])),
319
+ )
320
+
321
+ scope, receive = _make_scope_and_receive(
322
+ "POST",
323
+ f"/threads/{thread_id}/stream/events",
324
+ {"thread_id": str(thread_id)},
325
+ body_dict={"channels": ["input"], "since": 0},
326
+ )
327
+ req = Request(scope, receive)
328
+ res = await protocol_event_stream(req)
329
+ assert isinstance(res, StreamingResponse)
330
+
331
+ frames: list[str] = []
332
+ try:
333
+ async for chunk in res.body_iterator:
334
+ frames.append(chunk)
335
+ # Replay emits immediately, break after replay
336
+ if "int-active" in chunk or len(frames) >= 2:
337
+ break
338
+ finally:
339
+ await res.body_iterator.aclose()
340
+
341
+ all_output = "".join(frames)
342
+ assert "int-old" not in all_output, "Zombie interrupt 'int-old' must NOT be replayed to client!"
343
+ assert "int-active" in all_output, "Active interrupt 'int-active' must be replayed to client!"
344
+