data-olympus 0.3.2__tar.gz → 0.3.4__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 (81) hide show
  1. {data_olympus-0.3.2 → data_olympus-0.3.4}/CHANGELOG.md +54 -1
  2. {data_olympus-0.3.2 → data_olympus-0.3.4}/PKG-INFO +1 -1
  3. {data_olympus-0.3.2 → data_olympus-0.3.4}/pyproject.toml +1 -1
  4. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/config.py +11 -2
  5. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/enforce_policy.py +15 -1
  6. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/server.py +21 -2
  7. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/session_metrics.py +51 -9
  8. {data_olympus-0.3.2 → data_olympus-0.3.4}/.gitignore +0 -0
  9. {data_olympus-0.3.2 → data_olympus-0.3.4}/LICENSE +0 -0
  10. {data_olympus-0.3.2 → data_olympus-0.3.4}/NOTICE +0 -0
  11. {data_olympus-0.3.2 → data_olympus-0.3.4}/README.md +0 -0
  12. {data_olympus-0.3.2 → data_olympus-0.3.4}/SPEC.md +0 -0
  13. {data_olympus-0.3.2 → data_olympus-0.3.4}/benchmarks/README.md +0 -0
  14. {data_olympus-0.3.2 → data_olympus-0.3.4}/bin/_kb_detect_workspace.sh +0 -0
  15. {data_olympus-0.3.2 → data_olympus-0.3.4}/bin/_kb_enforce.py +0 -0
  16. {data_olympus-0.3.2 → data_olympus-0.3.4}/bin/_kb_fallback.py +0 -0
  17. {data_olympus-0.3.2 → data_olympus-0.3.4}/bin/kb +0 -0
  18. {data_olympus-0.3.2 → data_olympus-0.3.4}/bin/kb-enforce-hook +0 -0
  19. {data_olympus-0.3.2 → data_olympus-0.3.4}/bin/opencode/data-olympus-gate.ts +0 -0
  20. {data_olympus-0.3.2 → data_olympus-0.3.4}/deploy/k8s/README.md +0 -0
  21. {data_olympus-0.3.2 → data_olympus-0.3.4}/deploy/k8s/read-replica/README.md +0 -0
  22. {data_olympus-0.3.2 → data_olympus-0.3.4}/docs/releases/README.md +0 -0
  23. {data_olympus-0.3.2 → data_olympus-0.3.4}/example-bundle/projects/acme-app/README.md +0 -0
  24. {data_olympus-0.3.2 → data_olympus-0.3.4}/example-bundle/projects/acme-app/components/api/README.md +0 -0
  25. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/__init__.py +0 -0
  26. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/audit_log.py +0 -0
  27. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/audit_trailers.py +0 -0
  28. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/auth.py +0 -0
  29. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/cli/__init__.py +0 -0
  30. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/cli/import_cmd.py +0 -0
  31. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/cli/indexgen.py +0 -0
  32. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/cli/main.py +0 -0
  33. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/cli/report_cmd.py +0 -0
  34. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/cooccurrence.py +0 -0
  35. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/dedup.py +0 -0
  36. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/durable.py +0 -0
  37. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/embeddings.py +0 -0
  38. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/format/__init__.py +0 -0
  39. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/format/document.py +0 -0
  40. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/format/frontmatter.py +0 -0
  41. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/format/lint.py +0 -0
  42. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/format/validate.py +0 -0
  43. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/git_ops.py +0 -0
  44. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/health.py +0 -0
  45. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/importer/__init__.py +0 -0
  46. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/importer/adr.py +0 -0
  47. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/importer/flat.py +0 -0
  48. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/importer/model.py +0 -0
  49. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/importer/okf.py +0 -0
  50. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/importer/run.py +0 -0
  51. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/importer/stamp.py +0 -0
  52. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/index.py +0 -0
  53. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/markdown_parse.py +0 -0
  54. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/models.py +0 -0
  55. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/onboarding.py +0 -0
  56. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/onboarding_inflight.py +0 -0
  57. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/onboarding_playbook.py +0 -0
  58. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/pending.py +0 -0
  59. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/principals.py +0 -0
  60. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/prompts.py +0 -0
  61. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/push_queue.py +0 -0
  62. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/query_expansion.py +0 -0
  63. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/rate_limit.py +0 -0
  64. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/refresh.py +0 -0
  65. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/report.py +0 -0
  66. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/rest_api.py +0 -0
  67. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/safe_id.py +0 -0
  68. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/search_gate.py +0 -0
  69. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/search_shortcut.py +0 -0
  70. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/setup_wizard.py +0 -0
  71. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/thin_pointer.py +0 -0
  72. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/tools_audit.py +0 -0
  73. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/tools_enforce.py +0 -0
  74. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/tools_onboarding.py +0 -0
  75. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/tools_read.py +0 -0
  76. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/tools_write.py +0 -0
  77. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/trigram.py +0 -0
  78. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/viewer/__init__.py +0 -0
  79. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/viewer/generator.py +0 -0
  80. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/worktrees.py +0 -0
  81. {data_olympus-0.3.2 → data_olympus-0.3.4}/src/data_olympus/write_gate.py +0 -0
@@ -12,6 +12,58 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.3.4] - 2026-07-05
16
+
17
+ ### Fixed
18
+
19
+ - **Enforcement gate false-positives on prose that merely mentions a governed
20
+ keyword, for tool calls with a known file path.** `IntentClassifier.classify`
21
+ scanned `action_diff` (the raw Write/Edit content or Bash command text, up
22
+ to 4000 chars) for free-text keywords like `auth`, `rls`, `tenant`, `secret`
23
+ in addition to `intent`, for every tool call. A scratch write whose text
24
+ simply discussed one of those words as a topic - not an actual governed
25
+ action - tripped the pre-tool gate regardless of the real (ungoverned)
26
+ write target, and a fresh `kb_consult` call did not clear it because the
27
+ consult's own classification (`intent`-only) and the gate's classification
28
+ (`intent + action_diff`) disagreed on whether the action was governed.
29
+ Keyword matching on `action_diff` now only applies when the tool call
30
+ carries no `action_path` (Bash commands and OpenCode's `patch`, whose
31
+ content is the only signal available); when an `action_path` is present
32
+ (Write/Edit/MultiEdit), classification uses the path globs and dependency
33
+ command patterns, unchanged, so this keeps full detection for real governed
34
+ edits (a patch touching `package.json`) without flagging unrelated prose in
35
+ files the path globs don't consider governed. Found via a company-knowledge
36
+ KB audit session that reproduced it live (a scratch `.txt` file containing
37
+ "RLS tenant boundaries auth trust model" was blocked with no governed path
38
+ or command in sight); independently reviewed via `codex exec` before
39
+ release.
40
+ - The audit's other finding (CX1: the pre-tool gate and the pre-commit
41
+ `report --staged` gate resolving two different workspace keys) was
42
+ already fixed in v0.2.0 (#64, worktree-invariant workspace key); no
43
+ further change needed. It was still visible in that audit only because
44
+ it predated the deployed kn-dev redeploy.
45
+
46
+ ## [0.3.3] - 2026-07-05
47
+
48
+ ### Fixed
49
+
50
+ - **MCP streamable-HTTP session churn under long-lived clients.** A persistent
51
+ client (notably `opencode serve`) that holds the `GET /mcp` SSE stream open was
52
+ being forced to reconnect on a cycle, piling up abandoned transports on the
53
+ server and leaking event listeners on the client until it degraded. Two causes
54
+ addressed:
55
+ - The activity middleware now keeps a session non-idle for as long as its SSE
56
+ stream is actually open (re-stamping every `KB_SESSION_TOUCH_INTERVAL_SEC`,
57
+ default 30s, clamped to a third of the idle window), so the reaper evicts a
58
+ session only after its stream has genuinely closed. With that safety in
59
+ place, the idle-reap default (`KB_SESSION_IDLE_TIMEOUT_SEC`) drops from 1800s
60
+ to 300s, so an abandoned handshake is cleared in minutes instead of half an
61
+ hour.
62
+ - The shipped ingress manifest (`deploy/k8s/ingress.yaml`) now sets a long
63
+ `proxy-read-timeout`/`proxy-send-timeout` and disables buffering, so a proxy
64
+ with a short default idle timeout (nginx: 60s) no longer closes the SSE
65
+ stream and triggers the reconnect loop. See `docs/serving.md`.
66
+
15
67
  ## [0.3.2] - 2026-07-05
16
68
 
17
69
  ### Fixed
@@ -1029,7 +1081,8 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1029
1081
  - `docs/adoption.md`: bring-your-own-KB guide (author, lint, index, serve, wire an agent).
1030
1082
  - `docs/comparison.md`: how data-olympus relates to OKF, enterprise catalogs, markdown KB tools, agent-context conventions, RAG, and ADR tooling.
1031
1083
 
1032
- [Unreleased]: https://github.com/knaisoma/data-olympus/compare/v0.3.2...HEAD
1084
+ [Unreleased]: https://github.com/knaisoma/data-olympus/compare/v0.3.3...HEAD
1085
+ [0.3.3]: https://github.com/knaisoma/data-olympus/compare/v0.3.2...v0.3.3
1033
1086
  [0.3.2]: https://github.com/knaisoma/data-olympus/compare/v0.3.1...v0.3.2
1034
1087
  [0.3.1]: https://github.com/knaisoma/data-olympus/compare/v0.3.0...v0.3.1
1035
1088
  [0.3.0]: https://github.com/knaisoma/data-olympus/compare/v0.2.0...v0.3.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: data-olympus
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Governance-grade knowledge-base format (OKF-compatible) plus CLI and single-writer MCP server
5
5
  Project-URL: Homepage, https://github.com/knaisoma/data-olympus
6
6
  Project-URL: Repository, https://github.com/knaisoma/data-olympus
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "data-olympus"
3
- version = "0.3.2"
3
+ version = "0.3.4"
4
4
  description = "Governance-grade knowledge-base format (OKF-compatible) plus CLI and single-writer MCP server"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -65,8 +65,15 @@ class Config:
65
65
  # Streamable-http session reaping: terminate transports idle beyond this many
66
66
  # seconds to bound _server_instances (see session_metrics). 0 disables the
67
67
  # reaper (observability-only). The scan runs every session_reap_interval_sec.
68
- session_idle_timeout_sec: int = 1800
68
+ # An in-flight GET SSE stream is kept non-idle by the activity middleware, so
69
+ # this window only clears sessions whose stream has actually closed (an
70
+ # abandoned handshake); 5 minutes keeps those from piling up.
71
+ session_idle_timeout_sec: int = 300
69
72
  session_reap_interval_sec: int = 60
73
+ # How often the activity middleware re-stamps a session while its GET SSE
74
+ # stream is open, so a quiet-but-connected client is never reaped. Clamped
75
+ # below session_idle_timeout_sec at wiring time.
76
+ session_touch_interval_sec: int = 30
70
77
  # Optional override for the status-aware reranker's status->weight map
71
78
  # (issue #37). None means "use the Index built-in default map". A negative
72
79
  # weight boosts an in-force status, a positive one penalizes a retired one.
@@ -191,8 +198,9 @@ def load_config() -> Config:
191
198
  auth_principals = parse_principals_env(os.getenv("KB_AUTH_PRINCIPALS", ""))
192
199
  consult_ttl_sec = int(os.getenv("KB_CONSULT_TTL_SEC", "300"))
193
200
  ledger_path = os.getenv("KB_LEDGER_PATH", "/state/ledger.json")
194
- session_idle_timeout_sec = int(os.getenv("KB_SESSION_IDLE_TIMEOUT_SEC", "1800"))
201
+ session_idle_timeout_sec = int(os.getenv("KB_SESSION_IDLE_TIMEOUT_SEC", "300"))
195
202
  session_reap_interval_sec = int(os.getenv("KB_SESSION_REAP_INTERVAL_SEC", "60"))
203
+ session_touch_interval_sec = int(os.getenv("KB_SESSION_TOUCH_INTERVAL_SEC", "30"))
196
204
  status_weights = _load_status_weights(os.getenv("KB_STATUS_WEIGHTS", ""))
197
205
  read_only = _env_bool(os.getenv("KB_READ_ONLY", ""))
198
206
  from data_olympus.cooccurrence import (
@@ -254,6 +262,7 @@ def load_config() -> Config:
254
262
  ledger_path=ledger_path,
255
263
  session_idle_timeout_sec=session_idle_timeout_sec,
256
264
  session_reap_interval_sec=session_reap_interval_sec,
265
+ session_touch_interval_sec=session_touch_interval_sec,
257
266
  status_weights=status_weights,
258
267
  read_only=read_only,
259
268
  cooccurrence_enabled=cooc_enabled,
@@ -81,7 +81,21 @@ class IntentClassifier:
81
81
  action_diff: str = "",
82
82
  ) -> ClassifyResult:
83
83
  signals: list[str] = []
84
- text = f"{intent} {action_diff}".lower()
84
+ # Free-text keyword signals always scan `intent` (a short, deliberate
85
+ # statement of what the agent is about to do - kb_consult's argument -
86
+ # so a keyword there is a real signal). They scan `action_diff` too,
87
+ # but ONLY when there is no `action_path`: `action_diff` is arbitrary
88
+ # write/command CONTENT up to 4000 chars (Write.content, Edit.new_string,
89
+ # Bash.command, OpenCode patch text), and when a concrete action_path is
90
+ # already given, path-glob matching below is the intended signal for
91
+ # that action - layering the free-text net on top blocked benign writes
92
+ # to ungoverned files that merely discussed a keyword as a topic (a
93
+ # scratch file quoting "RLS"/"auth" from a security standard). Bash
94
+ # commands and OpenCode's `patch` tool carry no action_path at all
95
+ # (their action_diff IS the only content available), so the free-text
96
+ # net still applies there - dropping it entirely would silently lose
97
+ # the only governed-decision signal for those two tool shapes.
98
+ text = intent.lower() if action_path else f"{intent} {action_diff}".lower()
85
99
  for kw, rx in self._keyword_res:
86
100
  if rx.search(text):
87
101
  signals.append(f"keyword:{kw}")
@@ -251,8 +251,9 @@ def build_app(
251
251
  audit_hmac_key: str = "",
252
252
  audit_max_bytes: int = 0,
253
253
  ledger_path: str | None = None,
254
- session_idle_timeout_sec: int = 1800,
254
+ session_idle_timeout_sec: int = 300,
255
255
  session_reap_interval_sec: int = 60,
256
+ session_touch_interval_sec: int = 30,
256
257
  status_weights: dict[str, float] | None = None,
257
258
  read_only: bool = False,
258
259
  embeddings_enabled: bool = False,
@@ -300,6 +301,7 @@ def build_app(
300
301
  audit_max_bytes=audit_max_bytes,
301
302
  session_idle_timeout_sec=session_idle_timeout_sec,
302
303
  session_reap_interval_sec=session_reap_interval_sec,
304
+ session_touch_interval_sec=session_touch_interval_sec,
303
305
  status_weights=status_weights,
304
306
  read_only=read_only,
305
307
  embeddings_enabled=embeddings_enabled,
@@ -888,6 +890,7 @@ def build_app_from_config(config: Config, *, bootstrap_now: bool = True) -> Fast
888
890
  ledger_path=config.ledger_path,
889
891
  session_idle_timeout_sec=config.session_idle_timeout_sec,
890
892
  session_reap_interval_sec=config.session_reap_interval_sec,
893
+ session_touch_interval_sec=config.session_touch_interval_sec,
891
894
  status_weights=config.status_weights,
892
895
  read_only=config.read_only,
893
896
  embeddings_enabled=config.embeddings_enabled,
@@ -983,9 +986,25 @@ def main() -> None:
983
986
  )
984
987
 
985
988
  tracker = SessionActivityTracker()
989
+ # Re-stamp an in-flight SSE session at least 3x per idle window, so a
990
+ # quiet-but-connected client is never reaped even if an operator sets a short
991
+ # idle timeout. Falls back to the configured interval when the idle window is
992
+ # generous.
993
+ _idle = config.session_idle_timeout_sec
994
+ _touch_interval = float(config.session_touch_interval_sec)
995
+ if _idle > 0:
996
+ # Always strictly below the idle window (>= 3 touches per window) even for
997
+ # a tiny idle value; a small positive floor avoids a zero/negative sleep.
998
+ _touch_interval = max(0.1, min(_touch_interval, _idle / 3))
986
999
  http_app = app.http_app(
987
1000
  transport="streamable-http",
988
- middleware=[StarletteMiddleware(SessionActivityMiddleware, tracker=tracker)],
1001
+ middleware=[
1002
+ StarletteMiddleware(
1003
+ SessionActivityMiddleware,
1004
+ tracker=tracker,
1005
+ touch_interval_sec=_touch_interval,
1006
+ )
1007
+ ],
989
1008
  )
990
1009
  # Health surfaces the live session count; the manager is only reachable once
991
1010
  # the lifespan has started, so this returns None before then (never raises).
@@ -45,6 +45,7 @@ these internals, discovery returns ``None`` and the count is reported as
45
45
  from __future__ import annotations
46
46
 
47
47
  import asyncio
48
+ import contextlib
48
49
  import logging
49
50
  import time
50
51
  from typing import TYPE_CHECKING, Any, Protocol
@@ -195,20 +196,61 @@ class SessionActivityTracker:
195
196
 
196
197
  class SessionActivityMiddleware:
197
198
  """ASGI middleware that stamps :class:`SessionActivityTracker` on each request
198
- carrying an ``mcp-session-id`` header. Pure pass-through otherwise; adds no
199
- latency beyond a header scan."""
199
+ carrying an ``mcp-session-id`` header.
200
+
201
+ The ``GET /mcp`` Server-Sent-Events stream is a *single* long-lived request
202
+ that emits no further ASGI events while the session is quiet. Stamping only
203
+ at request start would let the idle reaper evict a still-connected client
204
+ after the idle window (forcing a reconnect, the exact churn that piles up
205
+ transports server-side and leaks listeners in a persistent client like
206
+ ``opencode serve``). So for an in-flight GET stream this middleware re-stamps
207
+ the session every ``touch_interval_sec`` for as long as the stream stays open;
208
+ when the client disconnects the request completes, re-stamping stops, and only
209
+ then does the session go idle and become reap-eligible. Short GET/POST
210
+ requests finish before the first tick, so they pay only the initial stamp.
211
+ """
200
212
 
201
- def __init__(self, app: ASGIApp, tracker: SessionActivityTracker) -> None:
213
+ def __init__(
214
+ self,
215
+ app: ASGIApp,
216
+ tracker: SessionActivityTracker,
217
+ *,
218
+ touch_interval_sec: float = 30.0,
219
+ ) -> None:
202
220
  self.app = app
203
221
  self._tracker = tracker
222
+ self._touch_interval = touch_interval_sec
204
223
 
205
224
  async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
206
- if scope.get("type") == "http":
207
- for key, value in scope.get("headers", []):
208
- if key.lower() == _MCP_SESSION_ID_HEADER and value:
209
- self._tracker.touch(value.decode("latin-1"))
210
- break
211
- await self.app(scope, receive, send)
225
+ if scope.get("type") != "http":
226
+ await self.app(scope, receive, send)
227
+ return
228
+ session_id = ""
229
+ for key, value in scope.get("headers", []):
230
+ if key.lower() == _MCP_SESSION_ID_HEADER and value:
231
+ session_id = value.decode("latin-1")
232
+ break
233
+ if not session_id:
234
+ await self.app(scope, receive, send)
235
+ return
236
+ self._tracker.touch(session_id)
237
+ # Only the long-lived GET SSE stream needs keep-alive re-stamping.
238
+ if scope.get("method") != "GET":
239
+ await self.app(scope, receive, send)
240
+ return
241
+
242
+ async def _keep_active() -> None:
243
+ while True:
244
+ await asyncio.sleep(self._touch_interval)
245
+ self._tracker.touch(session_id)
246
+
247
+ task = asyncio.create_task(_keep_active())
248
+ try:
249
+ await self.app(scope, receive, send)
250
+ finally:
251
+ task.cancel()
252
+ with contextlib.suppress(asyncio.CancelledError, Exception):
253
+ await task
212
254
 
213
255
 
214
256
  async def reap_idle_sessions(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes