data-olympus 0.3.1__tar.gz → 0.3.3__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.
- {data_olympus-0.3.1 → data_olympus-0.3.3}/CHANGELOG.md +40 -1
- {data_olympus-0.3.1 → data_olympus-0.3.3}/PKG-INFO +1 -1
- {data_olympus-0.3.1 → data_olympus-0.3.3}/pyproject.toml +1 -1
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/config.py +21 -2
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/rest_api.py +30 -13
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/server.py +52 -12
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/session_metrics.py +51 -9
- {data_olympus-0.3.1 → data_olympus-0.3.3}/.gitignore +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/LICENSE +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/NOTICE +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/README.md +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/SPEC.md +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/benchmarks/README.md +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/bin/_kb_detect_workspace.sh +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/bin/_kb_enforce.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/bin/_kb_fallback.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/bin/kb +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/bin/kb-enforce-hook +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/bin/opencode/data-olympus-gate.ts +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/deploy/k8s/README.md +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/deploy/k8s/read-replica/README.md +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/docs/releases/README.md +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/example-bundle/projects/acme-app/README.md +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/example-bundle/projects/acme-app/components/api/README.md +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/__init__.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/audit_log.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/audit_trailers.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/auth.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/cli/__init__.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/cli/import_cmd.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/cli/indexgen.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/cli/main.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/cli/report_cmd.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/cooccurrence.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/dedup.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/durable.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/embeddings.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/enforce_policy.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/format/__init__.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/format/document.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/format/frontmatter.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/format/lint.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/format/validate.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/git_ops.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/health.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/importer/__init__.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/importer/adr.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/importer/flat.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/importer/model.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/importer/okf.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/importer/run.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/importer/stamp.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/index.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/markdown_parse.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/models.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/onboarding.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/onboarding_inflight.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/onboarding_playbook.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/pending.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/principals.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/prompts.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/push_queue.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/query_expansion.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/rate_limit.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/refresh.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/report.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/safe_id.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/search_gate.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/search_shortcut.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/setup_wizard.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/thin_pointer.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/tools_audit.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/tools_enforce.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/tools_onboarding.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/tools_read.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/tools_write.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/trigram.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/viewer/__init__.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/viewer/generator.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/worktrees.py +0 -0
- {data_olympus-0.3.1 → data_olympus-0.3.3}/src/data_olympus/write_gate.py +0 -0
|
@@ -12,6 +12,43 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.3.3] - 2026-07-05
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **MCP streamable-HTTP session churn under long-lived clients.** A persistent
|
|
20
|
+
client (notably `opencode serve`) that holds the `GET /mcp` SSE stream open was
|
|
21
|
+
being forced to reconnect on a cycle, piling up abandoned transports on the
|
|
22
|
+
server and leaking event listeners on the client until it degraded. Two causes
|
|
23
|
+
addressed:
|
|
24
|
+
- The activity middleware now keeps a session non-idle for as long as its SSE
|
|
25
|
+
stream is actually open (re-stamping every `KB_SESSION_TOUCH_INTERVAL_SEC`,
|
|
26
|
+
default 30s, clamped to a third of the idle window), so the reaper evicts a
|
|
27
|
+
session only after its stream has genuinely closed. With that safety in
|
|
28
|
+
place, the idle-reap default (`KB_SESSION_IDLE_TIMEOUT_SEC`) drops from 1800s
|
|
29
|
+
to 300s, so an abandoned handshake is cleared in minutes instead of half an
|
|
30
|
+
hour.
|
|
31
|
+
- The shipped ingress manifest (`deploy/k8s/ingress.yaml`) now sets a long
|
|
32
|
+
`proxy-read-timeout`/`proxy-send-timeout` and disables buffering, so a proxy
|
|
33
|
+
with a short default idle timeout (nginx: 60s) no longer closes the SSE
|
|
34
|
+
stream and triggers the reconnect loop. See `docs/serving.md`.
|
|
35
|
+
|
|
36
|
+
## [0.3.2] - 2026-07-05
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **`gate/check` no longer self-DoSes multi-agent fleets with 429s.** The
|
|
41
|
+
enforcement hook calls `POST /api/v1/gate/check` (and `kb_gate_check`) once per
|
|
42
|
+
gated tool action for every agent, but it shared the write/consult quota
|
|
43
|
+
(`KB_RATE_LIMIT_PER_HOUR`, default 100). Behind an ingress with no auth, all
|
|
44
|
+
clients collapse to a single limiter bucket, so a few active agents exhausted
|
|
45
|
+
the hourly quota in minutes and everything after got `429 Too Many Requests`.
|
|
46
|
+
gate-check now has its own ceiling, `KB_GATE_CHECK_RATE_LIMIT_PER_HOUR`,
|
|
47
|
+
defaulting to `0` (unthrottled, consistent with the read routes) since it is a
|
|
48
|
+
mandatory per-action probe that does only cheap classification and no writes.
|
|
49
|
+
Set a positive value for an explicit backstop. `consult` and
|
|
50
|
+
`onboarding/cleanup-plan` stay throttled by `KB_RATE_LIMIT_PER_HOUR`.
|
|
51
|
+
|
|
15
52
|
## [0.3.1] - 2026-07-05
|
|
16
53
|
|
|
17
54
|
### Fixed
|
|
@@ -1013,7 +1050,9 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
1013
1050
|
- `docs/adoption.md`: bring-your-own-KB guide (author, lint, index, serve, wire an agent).
|
|
1014
1051
|
- `docs/comparison.md`: how data-olympus relates to OKF, enterprise catalogs, markdown KB tools, agent-context conventions, RAG, and ADR tooling.
|
|
1015
1052
|
|
|
1016
|
-
[Unreleased]: https://github.com/knaisoma/data-olympus/compare/v0.3.
|
|
1053
|
+
[Unreleased]: https://github.com/knaisoma/data-olympus/compare/v0.3.3...HEAD
|
|
1054
|
+
[0.3.3]: https://github.com/knaisoma/data-olympus/compare/v0.3.2...v0.3.3
|
|
1055
|
+
[0.3.2]: https://github.com/knaisoma/data-olympus/compare/v0.3.1...v0.3.2
|
|
1017
1056
|
[0.3.1]: https://github.com/knaisoma/data-olympus/compare/v0.3.0...v0.3.1
|
|
1018
1057
|
[0.3.0]: https://github.com/knaisoma/data-olympus/compare/v0.2.0...v0.3.0
|
|
1019
1058
|
[0.2.0]: https://github.com/knaisoma/data-olympus/compare/v0.1.1...v0.2.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: data-olympus
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
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
|
|
@@ -28,6 +28,14 @@ class Config:
|
|
|
28
28
|
write_block_paths: list[str] = field(default_factory=list)
|
|
29
29
|
rate_limit_per_hour: int = 100
|
|
30
30
|
rate_limit_per_ip_per_hour: int = 0
|
|
31
|
+
# Separate ceiling for the high-frequency /api/v1/gate/check route (the
|
|
32
|
+
# enforcement hook's per-tool-action freshness probe). 0 (default) disables
|
|
33
|
+
# throttling for that route: it is a mandatory, once-per-tool-call, read-only
|
|
34
|
+
# probe, so any fixed hourly quota self-DoSes an active multi-agent fleet
|
|
35
|
+
# (behind ingress all clients collapse to one limiter bucket). Reads are
|
|
36
|
+
# already unthrottled; this keeps gate/check consistent with them. Set a
|
|
37
|
+
# positive value only if you want an explicit backstop.
|
|
38
|
+
gate_check_rate_limit_per_hour: int = 0
|
|
31
39
|
max_text_bytes: int = 262144
|
|
32
40
|
max_postimage_bytes: int = 1048576
|
|
33
41
|
max_body_bytes: int = 2097152
|
|
@@ -57,8 +65,15 @@ class Config:
|
|
|
57
65
|
# Streamable-http session reaping: terminate transports idle beyond this many
|
|
58
66
|
# seconds to bound _server_instances (see session_metrics). 0 disables the
|
|
59
67
|
# reaper (observability-only). The scan runs every session_reap_interval_sec.
|
|
60
|
-
|
|
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
|
|
61
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
|
|
62
77
|
# Optional override for the status-aware reranker's status->weight map
|
|
63
78
|
# (issue #37). None means "use the Index built-in default map". A negative
|
|
64
79
|
# weight boosts an in-force status, a positive one penalizes a retired one.
|
|
@@ -155,6 +170,7 @@ def load_config() -> Config:
|
|
|
155
170
|
write_block_paths = _split_csv(os.getenv("KB_WRITE_BLOCK_PATHS", ""))
|
|
156
171
|
rate_limit_per_hour = int(os.getenv("KB_RATE_LIMIT_PER_HOUR", "100"))
|
|
157
172
|
rate_limit_per_ip_per_hour = int(os.getenv("KB_RATE_LIMIT_PER_IP_PER_HOUR", "0"))
|
|
173
|
+
gate_check_rate_limit_per_hour = int(os.getenv("KB_GATE_CHECK_RATE_LIMIT_PER_HOUR", "0"))
|
|
158
174
|
max_text_bytes = int(os.getenv("KB_MAX_TEXT_BYTES", "262144"))
|
|
159
175
|
max_postimage_bytes = int(os.getenv("KB_MAX_POSTIMAGE_BYTES", "1048576"))
|
|
160
176
|
max_body_bytes = int(os.getenv("KB_MAX_BODY_BYTES", "2097152"))
|
|
@@ -182,8 +198,9 @@ def load_config() -> Config:
|
|
|
182
198
|
auth_principals = parse_principals_env(os.getenv("KB_AUTH_PRINCIPALS", ""))
|
|
183
199
|
consult_ttl_sec = int(os.getenv("KB_CONSULT_TTL_SEC", "300"))
|
|
184
200
|
ledger_path = os.getenv("KB_LEDGER_PATH", "/state/ledger.json")
|
|
185
|
-
session_idle_timeout_sec = int(os.getenv("KB_SESSION_IDLE_TIMEOUT_SEC", "
|
|
201
|
+
session_idle_timeout_sec = int(os.getenv("KB_SESSION_IDLE_TIMEOUT_SEC", "300"))
|
|
186
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"))
|
|
187
204
|
status_weights = _load_status_weights(os.getenv("KB_STATUS_WEIGHTS", ""))
|
|
188
205
|
read_only = _env_bool(os.getenv("KB_READ_ONLY", ""))
|
|
189
206
|
from data_olympus.cooccurrence import (
|
|
@@ -226,6 +243,7 @@ def load_config() -> Config:
|
|
|
226
243
|
write_block_paths=write_block_paths,
|
|
227
244
|
rate_limit_per_hour=rate_limit_per_hour,
|
|
228
245
|
rate_limit_per_ip_per_hour=rate_limit_per_ip_per_hour,
|
|
246
|
+
gate_check_rate_limit_per_hour=gate_check_rate_limit_per_hour,
|
|
229
247
|
max_text_bytes=max_text_bytes,
|
|
230
248
|
max_postimage_bytes=max_postimage_bytes,
|
|
231
249
|
max_body_bytes=max_body_bytes,
|
|
@@ -244,6 +262,7 @@ def load_config() -> Config:
|
|
|
244
262
|
ledger_path=ledger_path,
|
|
245
263
|
session_idle_timeout_sec=session_idle_timeout_sec,
|
|
246
264
|
session_reap_interval_sec=session_reap_interval_sec,
|
|
265
|
+
session_touch_interval_sec=session_touch_interval_sec,
|
|
247
266
|
status_weights=status_weights,
|
|
248
267
|
read_only=read_only,
|
|
249
268
|
cooccurrence_enabled=cooc_enabled,
|
|
@@ -32,6 +32,7 @@ if TYPE_CHECKING:
|
|
|
32
32
|
from starlette.requests import Request
|
|
33
33
|
|
|
34
34
|
from data_olympus.models import HealthResponse
|
|
35
|
+
from data_olympus.rate_limit import SlidingWindowLimiter
|
|
35
36
|
from data_olympus.server import ServerState
|
|
36
37
|
|
|
37
38
|
|
|
@@ -285,22 +286,33 @@ async def _read_json_capped(
|
|
|
285
286
|
)
|
|
286
287
|
|
|
287
288
|
|
|
289
|
+
_USE_WRITE_LIMITER: Any = object()
|
|
290
|
+
|
|
291
|
+
|
|
288
292
|
def _rate_limited(
|
|
289
293
|
state: ServerState, request: Request, principal: Principal,
|
|
294
|
+
*, limiter: SlidingWindowLimiter | None = _USE_WRITE_LIMITER,
|
|
290
295
|
) -> JSONResponse | None:
|
|
291
|
-
"""Apply
|
|
292
|
-
|
|
293
|
-
The consult /
|
|
294
|
-
|
|
295
|
-
without bound.
|
|
296
|
-
(remote_addr, principal name)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
296
|
+
"""Apply a sliding-window limiter to an enforcement-plane route.
|
|
297
|
+
|
|
298
|
+
The consult / cleanup-plan routes were previously unthrottled (item 6), so an
|
|
299
|
+
anonymous or authenticated caller could hammer the classifier and ledger
|
|
300
|
+
without bound. They reuse the write limiter (``state.rate_limiter``), keyed by
|
|
301
|
+
(remote_addr, principal name). Returns a 429 JSONResponse when over quota, else
|
|
302
|
+
None.
|
|
303
|
+
|
|
304
|
+
``limiter`` overrides which limiter to consult. The high-frequency
|
|
305
|
+
/api/v1/gate/check route passes ``state.gate_rate_limiter`` (None by default),
|
|
306
|
+
so it is unthrottled unless an operator sets an explicit ceiling: it is a
|
|
307
|
+
mandatory once-per-tool-action probe, and any fixed quota shared across a
|
|
308
|
+
fleet (all clients collapse to one bucket behind ingress) self-DoSes with 429s.
|
|
309
|
+
|
|
310
|
+
When the resolved limiter is absent (a read-only deployment, or gate/check with
|
|
311
|
+
no ceiling configured) throttling is skipped.
|
|
302
312
|
"""
|
|
303
|
-
limiter
|
|
313
|
+
# Distinguish "no limiter arg" (use the write limiter) from an explicit None
|
|
314
|
+
# (the gate-check limiter with no ceiling: throttling is genuinely off).
|
|
315
|
+
limiter = state.rate_limiter if limiter is _USE_WRITE_LIMITER else limiter
|
|
304
316
|
if limiter is None:
|
|
305
317
|
return None
|
|
306
318
|
remote_addr = request.client.host if request.client else "unknown"
|
|
@@ -662,7 +674,12 @@ def register_routes(
|
|
|
662
674
|
principal, denied = _authorize(request, registry)
|
|
663
675
|
if denied is not None:
|
|
664
676
|
return denied
|
|
665
|
-
|
|
677
|
+
# gate/check uses its own limiter (unthrottled by default): it is the
|
|
678
|
+
# hook's once-per-tool-action probe, so it must not share the write /
|
|
679
|
+
# consult quota (that self-DoSes a fleet, see _rate_limited).
|
|
680
|
+
if (throttled := _rate_limited(
|
|
681
|
+
state, request, principal, limiter=state.gate_rate_limiter
|
|
682
|
+
)) is not None:
|
|
666
683
|
return throttled
|
|
667
684
|
import time as _time
|
|
668
685
|
|
|
@@ -123,6 +123,7 @@ class ServerState:
|
|
|
123
123
|
push_queue: PushQueue | None = None,
|
|
124
124
|
pending: PendingQueue | None = None,
|
|
125
125
|
rate_limiter: SlidingWindowLimiter | None = None,
|
|
126
|
+
gate_rate_limiter: SlidingWindowLimiter | None = None,
|
|
126
127
|
blocklist: PathBlocklist | None = None,
|
|
127
128
|
audit_log: AuditLog | None = None,
|
|
128
129
|
classifier: IntentClassifier | None = None,
|
|
@@ -147,6 +148,10 @@ class ServerState:
|
|
|
147
148
|
self.push_queue: PushQueue | None = push_queue
|
|
148
149
|
self.pending: PendingQueue | None = pending
|
|
149
150
|
self.rate_limiter: SlidingWindowLimiter | None = rate_limiter
|
|
151
|
+
# Separate limiter for /api/v1/gate/check (None = unthrottled, the default).
|
|
152
|
+
# Kept distinct from `rate_limiter` so gate-check traffic and the write /
|
|
153
|
+
# consult / cleanup-plan quota never share a bucket.
|
|
154
|
+
self.gate_rate_limiter: SlidingWindowLimiter | None = gate_rate_limiter
|
|
150
155
|
self.blocklist: PathBlocklist | None = blocklist
|
|
151
156
|
self.audit_log: AuditLog | None = audit_log
|
|
152
157
|
# Process-wide write serializer (scope item 1): one instance shared by
|
|
@@ -232,6 +237,7 @@ def build_app(
|
|
|
232
237
|
confidence_threshold: float = 0.85,
|
|
233
238
|
rate_limit_per_hour: int = 100,
|
|
234
239
|
rate_limit_per_ip_per_hour: int = 0,
|
|
240
|
+
gate_check_rate_limit_per_hour: int = 0,
|
|
235
241
|
max_text_bytes: int = 262144,
|
|
236
242
|
max_postimage_bytes: int = 1048576,
|
|
237
243
|
max_body_bytes: int = 2097152,
|
|
@@ -245,8 +251,9 @@ def build_app(
|
|
|
245
251
|
audit_hmac_key: str = "",
|
|
246
252
|
audit_max_bytes: int = 0,
|
|
247
253
|
ledger_path: str | None = None,
|
|
248
|
-
session_idle_timeout_sec: int =
|
|
254
|
+
session_idle_timeout_sec: int = 300,
|
|
249
255
|
session_reap_interval_sec: int = 60,
|
|
256
|
+
session_touch_interval_sec: int = 30,
|
|
250
257
|
status_weights: dict[str, float] | None = None,
|
|
251
258
|
read_only: bool = False,
|
|
252
259
|
embeddings_enabled: bool = False,
|
|
@@ -279,6 +286,7 @@ def build_app(
|
|
|
279
286
|
write_block_paths=write_block_paths or [],
|
|
280
287
|
rate_limit_per_hour=rate_limit_per_hour,
|
|
281
288
|
rate_limit_per_ip_per_hour=rate_limit_per_ip_per_hour,
|
|
289
|
+
gate_check_rate_limit_per_hour=gate_check_rate_limit_per_hour,
|
|
282
290
|
max_text_bytes=max_text_bytes,
|
|
283
291
|
max_postimage_bytes=max_postimage_bytes,
|
|
284
292
|
max_body_bytes=max_body_bytes,
|
|
@@ -293,6 +301,7 @@ def build_app(
|
|
|
293
301
|
audit_max_bytes=audit_max_bytes,
|
|
294
302
|
session_idle_timeout_sec=session_idle_timeout_sec,
|
|
295
303
|
session_reap_interval_sec=session_reap_interval_sec,
|
|
304
|
+
session_touch_interval_sec=session_touch_interval_sec,
|
|
296
305
|
status_weights=status_weights,
|
|
297
306
|
read_only=read_only,
|
|
298
307
|
embeddings_enabled=embeddings_enabled,
|
|
@@ -393,6 +402,13 @@ def build_app(
|
|
|
393
402
|
max_per_hour=config.rate_limit_per_hour,
|
|
394
403
|
max_per_ip_per_hour=config.rate_limit_per_ip_per_hour,
|
|
395
404
|
)
|
|
405
|
+
# gate/check gets its own limiter only when a positive ceiling is set;
|
|
406
|
+
# 0 (default) leaves it unthrottled (see Config.gate_check_rate_limit_per_hour).
|
|
407
|
+
gate_rate_limiter = (
|
|
408
|
+
SlidingWindowLimiter(max_per_hour=config.gate_check_rate_limit_per_hour)
|
|
409
|
+
if config.gate_check_rate_limit_per_hour > 0
|
|
410
|
+
else None
|
|
411
|
+
)
|
|
396
412
|
blocklist = PathBlocklist(
|
|
397
413
|
tier_blocks=config.write_block_tiers,
|
|
398
414
|
path_blocks=config.write_block_paths,
|
|
@@ -401,6 +417,7 @@ def build_app(
|
|
|
401
417
|
state.push_queue = push_queue
|
|
402
418
|
state.pending = pending
|
|
403
419
|
state.rate_limiter = rate_limiter
|
|
420
|
+
state.gate_rate_limiter = gate_rate_limiter
|
|
404
421
|
state.blocklist = blocklist
|
|
405
422
|
|
|
406
423
|
if config.kb_remote_url and not config.read_only:
|
|
@@ -545,17 +562,22 @@ def build_app(
|
|
|
545
562
|
)
|
|
546
563
|
return resp.model_dump()
|
|
547
564
|
|
|
548
|
-
def _mcp_rate_limited() -> dict[str, object] | None:
|
|
549
|
-
"""Apply
|
|
565
|
+
def _mcp_rate_limited(*, gate: bool = False) -> dict[str, object] | None:
|
|
566
|
+
"""Apply a sliding-window limiter to an MCP enforcement-plane tool
|
|
550
567
|
(WP0b item (b)).
|
|
551
568
|
|
|
552
|
-
The REST consult /
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
569
|
+
The REST consult / cleanup-plan routes throttle via ``_rate_limited``; the
|
|
570
|
+
MCP tool paths did not, so an agent could hammer the classifier/ledger
|
|
571
|
+
unbounded. Key on the resolved principal (there is no per-request remote
|
|
572
|
+
addr on the MCP transport, so the limiter's per-agent quota is the
|
|
573
|
+
meaningful dimension). Returns a rejection dict when over quota, else None.
|
|
574
|
+
|
|
575
|
+
``gate=True`` (kb_gate_check) consults ``state.gate_rate_limiter`` instead
|
|
576
|
+
of the write limiter; that limiter is None by default, so the
|
|
577
|
+
high-frequency freshness probe is unthrottled unless explicitly capped,
|
|
578
|
+
matching the REST /api/v1/gate/check route. Skipped when the resolved
|
|
579
|
+
limiter is None (read-only deploy, or gate-check with no ceiling set)."""
|
|
580
|
+
limiter = state.gate_rate_limiter if gate else state.rate_limiter
|
|
559
581
|
if limiter is None:
|
|
560
582
|
return None
|
|
561
583
|
principal_name = _current_principal.get().name
|
|
@@ -768,7 +790,7 @@ def build_app(
|
|
|
768
790
|
) -> dict[str, object]:
|
|
769
791
|
"""Return a verdict (allow | consult_required) for a pending code action.
|
|
770
792
|
Governed actions require a fresh consultation on record."""
|
|
771
|
-
if (throttled := _mcp_rate_limited()) is not None:
|
|
793
|
+
if (throttled := _mcp_rate_limited(gate=True)) is not None:
|
|
772
794
|
return throttled
|
|
773
795
|
import time as _time
|
|
774
796
|
|
|
@@ -852,6 +874,7 @@ def build_app_from_config(config: Config, *, bootstrap_now: bool = True) -> Fast
|
|
|
852
874
|
confidence_threshold=config.confidence_threshold,
|
|
853
875
|
rate_limit_per_hour=config.rate_limit_per_hour,
|
|
854
876
|
rate_limit_per_ip_per_hour=config.rate_limit_per_ip_per_hour,
|
|
877
|
+
gate_check_rate_limit_per_hour=config.gate_check_rate_limit_per_hour,
|
|
855
878
|
max_text_bytes=config.max_text_bytes,
|
|
856
879
|
max_postimage_bytes=config.max_postimage_bytes,
|
|
857
880
|
max_body_bytes=config.max_body_bytes,
|
|
@@ -867,6 +890,7 @@ def build_app_from_config(config: Config, *, bootstrap_now: bool = True) -> Fast
|
|
|
867
890
|
ledger_path=config.ledger_path,
|
|
868
891
|
session_idle_timeout_sec=config.session_idle_timeout_sec,
|
|
869
892
|
session_reap_interval_sec=config.session_reap_interval_sec,
|
|
893
|
+
session_touch_interval_sec=config.session_touch_interval_sec,
|
|
870
894
|
status_weights=config.status_weights,
|
|
871
895
|
read_only=config.read_only,
|
|
872
896
|
embeddings_enabled=config.embeddings_enabled,
|
|
@@ -962,9 +986,25 @@ def main() -> None:
|
|
|
962
986
|
)
|
|
963
987
|
|
|
964
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))
|
|
965
999
|
http_app = app.http_app(
|
|
966
1000
|
transport="streamable-http",
|
|
967
|
-
middleware=[
|
|
1001
|
+
middleware=[
|
|
1002
|
+
StarletteMiddleware(
|
|
1003
|
+
SessionActivityMiddleware,
|
|
1004
|
+
tracker=tracker,
|
|
1005
|
+
touch_interval_sec=_touch_interval,
|
|
1006
|
+
)
|
|
1007
|
+
],
|
|
968
1008
|
)
|
|
969
1009
|
# Health surfaces the live session count; the manager is only reachable once
|
|
970
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.
|
|
199
|
-
|
|
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__(
|
|
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")
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
{data_olympus-0.3.1 → data_olympus-0.3.3}/example-bundle/projects/acme-app/components/api/README.md
RENAMED
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|