ltcai 11.7.0 → 12.0.0
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.
- package/README.md +100 -76
- package/docs/BENCHMARKS.md +9 -2
- package/docs/CHANGELOG.md +249 -0
- package/docs/CI_AND_RELEASE_GATES.md +126 -41
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +271 -103
- package/docs/ENTERPRISE.md +1 -1
- package/docs/LEGACY_COMPATIBILITY.md +10 -6
- package/docs/MULTI_AGENT_RUNTIME.md +4 -4
- package/docs/ONBOARDING.md +16 -4
- package/docs/OPERATIONS.md +14 -1
- package/docs/PERMISSION_MODE.md +14 -9
- package/docs/REALTIME_COLLABORATION.md +1 -1
- package/docs/ROADMAP.md +113 -0
- package/docs/TRUST_MODEL.md +28 -7
- package/docs/USABILITY_AUDIT.md +5 -0
- package/docs/WHY_LATTICE.md +13 -5
- package/docs/WORKFLOW_DESIGNER.md +2 -2
- package/docs/kg-schema.md +57 -7
- package/docs/mcp-tools.md +93 -82
- package/docs/security-model.md +6 -3
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common/__init__.py +1 -54
- package/lattice_brain/graph/_kg_common/extraction.py +459 -105
- package/lattice_brain/graph/_kg_common/normalize.py +305 -0
- package/lattice_brain/graph/_kg_common/patterns.py +275 -0
- package/lattice_brain/graph/_kg_common/relations.py +12 -3
- package/lattice_brain/graph/_kg_common/sections.py +107 -0
- package/lattice_brain/graph/_kg_common/text.py +14 -450
- package/lattice_brain/graph/_kg_constants.py +7 -0
- package/lattice_brain/ingestion/__init__.py +6 -3
- package/lattice_brain/multimodal/__init__.py +9 -3
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agent_worker_seam.py +44 -1
- package/latticeai/api/models.py +18 -110
- package/latticeai/api/search.py +7 -30
- package/latticeai/api/worker_compute.py +127 -106
- package/latticeai/api/worker_seams.py +17 -2
- package/latticeai/core/embedding_providers/__init__.py +16 -0
- package/latticeai/core/embedding_providers/autodetect.py +302 -0
- package/latticeai/core/embedding_providers/base.py +25 -0
- package/latticeai/core/embedding_providers/profiles.py +44 -0
- package/latticeai/core/embedding_providers/text.py +74 -8
- package/latticeai/core/http_origin.py +3 -3
- package/latticeai/core/messages.py +0 -5
- package/latticeai/core/policy.py +1 -6
- package/latticeai/core/quiet.py +1 -20
- package/latticeai/core/security.py +29 -83
- package/latticeai/core/sessions.py +95 -4
- package/latticeai/core/users.py +0 -38
- package/latticeai/core/vector_index/__init__.py +61 -0
- package/latticeai/core/vector_index/hnsw.py +383 -0
- package/latticeai/core/vector_index/sidecar.py +329 -0
- package/latticeai/models/router/catalog.py +2 -2
- package/latticeai/models/router/generation.py +176 -30
- package/latticeai/models/router/loading.py +150 -9
- package/latticeai/runtime/access_runtime.py +7 -4
- package/latticeai/runtime/brain_runtime.py +43 -9
- package/latticeai/runtime/build_phases/features.py +8 -31
- package/latticeai/runtime/build_phases/foundation.py +7 -16
- package/latticeai/runtime/build_phases/web.py +3 -3
- package/latticeai/runtime/build_phases/worker_profile.py +29 -27
- package/latticeai/runtime/runtime_context.py +0 -2
- package/latticeai/services/architecture_readiness.py +18 -19
- package/latticeai/services/process_audit.py +1 -22
- package/latticeai/services/product_readiness.py +39 -12
- package/latticeai/services/search_service.py +7 -0
- package/latticeai/services/voice_capture.py +8 -28
- package/latticeai/tools/__init__.py +12 -47
- package/latticeai/tools/commands.py +9 -15
- package/latticeai/tools/documents.py +12 -0
- package/latticeai/tools/knowledge.py +0 -6
- package/latticeai/tools/markup.py +152 -0
- package/package.json +4 -5
- package/requirements.txt +0 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_openapi_drift.mjs +3 -2
- package/scripts/check_server_i18n.mjs +5 -4
- package/scripts/compose_openapi.py +4 -1
- package/scripts/export_openapi.py +5 -4
- package/scripts/gen_worker_allowlist_fixture.py +2 -2
- package/scripts/openapi_route_families.json +19 -74
- package/scripts/publish_release.mjs +157 -0
- package/scripts/release_screen_claims.json +144 -28
- package/src-tauri/Cargo.lock +45 -10
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +47 -41
- package/static/app/assets/Act-Cf1L2709.js +2 -0
- package/static/app/assets/AdminConsole-DPAbLTYV.js +1 -0
- package/static/app/assets/Brain-DqamGrj-.js +2 -0
- package/static/app/assets/BrainHome-MHe2_RYs.js +2 -0
- package/static/app/assets/BrainSignals-CQPPfyyH.js +1 -0
- package/static/app/assets/Capture-DGdIH_Zc.js +1 -0
- package/static/app/assets/Chronicle-C-UlCJoJ.js +1 -0
- package/static/app/assets/CommandPalette-WNT4EqUX.js +1 -0
- package/static/app/assets/DigitalBrainExplorer-CEBH5Cwc.js +321 -0
- package/static/app/assets/Library-C6xd1dlf.js +1 -0
- package/static/app/assets/LivingBrain-BEk-0ohw.js +1 -0
- package/static/app/assets/ProductFlow-CZLm5iXh.js +1 -0
- package/static/app/assets/QueryClientProvider-B3OjqSyJ.js +1 -0
- package/static/app/assets/{ReviewCard-HXRle3qq.js → ReviewCard-CEHG6evf.js} +2 -2
- package/static/app/assets/RunsListPanel-CLtEJSRW.js +1 -0
- package/static/app/assets/System-CAxwBUXw.js +1 -0
- package/static/app/assets/WorkflowGraph-Dj10RuGE.js +1 -0
- package/static/app/assets/WorkflowsPanel-Kyeh_LIT.js +2 -0
- package/static/app/assets/actHelpers-CtSmK9Dw.js +1 -0
- package/static/app/assets/arrow-left-CRl5EO4D.js +1 -0
- package/static/app/assets/{bot-Cn8bWRuq.js → bot-DhUGRel2.js} +1 -1
- package/static/app/assets/brain-CLkhHsHF.js +1 -0
- package/static/app/assets/button-CmaEqG1T.js +1 -0
- package/static/app/assets/circle-check-CFgejkOS.js +1 -0
- package/static/app/assets/{circle-pause-CmzC_apg.js → circle-pause-l96izbxj.js} +1 -1
- package/static/app/assets/{circle-play-D8mW2aQ7.js → circle-play-CrZa25_q.js} +1 -1
- package/static/app/assets/{cpu-DZcdd0PZ.js → cpu-BaXudqwl.js} +1 -1
- package/static/app/assets/{download-bv1KEPGQ.js → download-hCVFPiyc.js} +1 -1
- package/static/app/assets/{folder-open-d-Pip5gr.js → folder-open-CHL82Yp7.js} +1 -1
- package/static/app/assets/{hard-drive-D20iavUb.js → hard-drive-DDzET7lk.js} +1 -1
- package/static/app/assets/index-CB93CZWW.css +2 -0
- package/static/app/assets/index-D2H-wSl6.js +13 -0
- package/static/app/assets/input-Df1CAY_I.js +1 -0
- package/static/app/assets/jsx-runtime-bzQ4Vb5N.js +1 -0
- package/static/app/assets/{link-2-BPJOFlAy.js → link-2-xNnTIX1_.js} +1 -1
- package/static/app/assets/{permissionCopy-ChdJd493.js → permissionCopy-D3aWHco-.js} +1 -1
- package/static/app/assets/primitives-BioD2slS.js +1 -0
- package/static/app/assets/search-BzBw8YcW.js +1 -0
- package/static/app/assets/{share-2-YNX_NtMU.js → share-2-FkzGf8Df.js} +1 -1
- package/static/app/assets/{shield-alert-DuQ3zrVL.js → shield-alert-B3dwzik4.js} +1 -1
- package/static/app/assets/sourceMeta-DQSY_tah.js +1 -0
- package/static/app/assets/textarea-P8o6pvOP.js +1 -0
- package/static/app/assets/useFocusTrap-hswOIkXE.js +1 -0
- package/static/app/assets/useMutation-OJLrYSRA.js +1 -0
- package/static/app/assets/workspace-BCuk3Ku9.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/lattice_brain/ingestion/pipeline.py +0 -108
- package/latticeai/api/local_files.py +0 -44
- package/latticeai/api/tools.py +0 -126
- package/latticeai/api/voice_capture.py +0 -32
- package/latticeai/core/agent_permission.py +0 -85
- package/scripts/agent_eval.py +0 -34
- package/scripts/brain_quality_eval.py +0 -37
- package/scripts/check_legacy_debt.mjs +0 -91
- package/scripts/check_python.py +0 -100
- package/scripts/chunking_parity_corpus.py +0 -449
- package/scripts/generate_agent_parity_fixtures.py +0 -771
- package/scripts/generate_chunking_parity_fixtures.py +0 -259
- package/static/app/assets/Act-BPcVAbOL.js +0 -1
- package/static/app/assets/AdminConsole-Bw1ATQL0.js +0 -1
- package/static/app/assets/Brain-CT92Kos0.js +0 -321
- package/static/app/assets/BrainHome-CFBkt1K_.js +0 -2
- package/static/app/assets/BrainSignals-ReLWF2H8.js +0 -1
- package/static/app/assets/Capture-BsTokYkk.js +0 -1
- package/static/app/assets/Chronicle-B6f0T9id.js +0 -1
- package/static/app/assets/CommandPalette-CuvjTv1u.js +0 -1
- package/static/app/assets/Library-BGJbG9Hd.js +0 -1
- package/static/app/assets/LivingBrain-DGYK_Jsa.js +0 -1
- package/static/app/assets/ProductFlow-DXBC6brE.js +0 -1
- package/static/app/assets/System-CMHSO9qM.js +0 -1
- package/static/app/assets/arrow-left-BfmkskWx.js +0 -1
- package/static/app/assets/brain-CQJberbE.js +0 -1
- package/static/app/assets/button-Ct9f2_oT.js +0 -1
- package/static/app/assets/circle-check-DruOxB-4.js +0 -1
- package/static/app/assets/index-D9x-kSNy.css +0 -2
- package/static/app/assets/index-Do83hDzJ.js +0 -10
- package/static/app/assets/input-BLXVNmj1.js +0 -1
- package/static/app/assets/primitives-Cv5tbZBY.js +0 -1
- package/static/app/assets/search-CT9aho2j.js +0 -1
- package/static/app/assets/textarea-DqwLnli4.js +0 -1
- package/static/app/assets/useFocusTrap-ZVI98jaW.js +0 -1
- package/static/app/assets/useMutation-CVC4qv_D.js +0 -1
- package/static/app/assets/useQuery-C7BeG4HU.js +0 -1
- package/static/app/assets/utils-CiFtIdZq.js +0 -4
- package/static/app/assets/workspace-DQz9vIId.js +0 -1
|
@@ -1,9 +1,27 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Token hashing, secret redaction, and the worker's per-user rate limiter.
|
|
2
|
+
|
|
3
|
+
What is *not* here any more, and why: password hashing / verification, the
|
|
4
|
+
``client_ip`` resolver with its per-IP login limiter, and the file-magic
|
|
5
|
+
extension check were all ported to ``lattice-auth`` / ``lattice-chat`` in
|
|
6
|
+
v11.6.0, when the front door and every write moved to Rust. They were left
|
|
7
|
+
behind as fully orphaned Python — no route, no service, no script called them
|
|
8
|
+
— and a second copy of an authentication rule that nothing enforces is worse
|
|
9
|
+
than no copy: it reads like a live guard.
|
|
10
|
+
|
|
11
|
+
What stays, stays because something still calls it:
|
|
12
|
+
|
|
13
|
+
* :func:`enforce_rate_limit` — charged by every ``/worker/*`` and ``/agent/*``
|
|
14
|
+
seam through ``_admit``.
|
|
15
|
+
* :func:`redact_secret_text` / :func:`redact_secrets` — and they additionally
|
|
16
|
+
generate the Rust redaction fixture via ``scripts/gen_redact_fixture.py``.
|
|
17
|
+
* the trusted-proxy allowlist — ``_peer_is_trusted_proxy`` is what
|
|
18
|
+
:mod:`latticeai.core.http_origin` (and through it the CSRF check) asks before
|
|
19
|
+
believing an ``X-Forwarded-Host``.
|
|
20
|
+
"""
|
|
2
21
|
|
|
3
22
|
import hashlib
|
|
4
23
|
import ipaddress
|
|
5
24
|
import re
|
|
6
|
-
import secrets
|
|
7
25
|
import threading
|
|
8
26
|
import time
|
|
9
27
|
from typing import Any, Dict, List
|
|
@@ -26,21 +44,6 @@ def sha256_hex(text: str) -> str:
|
|
|
26
44
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
27
45
|
|
|
28
46
|
|
|
29
|
-
def hash_password(password: str) -> str:
|
|
30
|
-
salt = secrets.token_hex(16)
|
|
31
|
-
key = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1)
|
|
32
|
-
return f"{salt}:{key.hex()}"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def verify_password(password: str, hashed: str) -> bool:
|
|
36
|
-
try:
|
|
37
|
-
salt, key_hex = hashed.split(":", 1)
|
|
38
|
-
key = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1)
|
|
39
|
-
return secrets.compare_digest(key.hex(), key_hex)
|
|
40
|
-
except Exception:
|
|
41
|
-
return False
|
|
42
|
-
|
|
43
|
-
|
|
44
47
|
def host_is_loopback(host: str) -> bool:
|
|
45
48
|
if host in {"localhost", "127.0.0.1", "::1"}:
|
|
46
49
|
return True
|
|
@@ -50,15 +53,15 @@ def host_is_loopback(host: str) -> bool:
|
|
|
50
53
|
return False
|
|
51
54
|
|
|
52
55
|
|
|
53
|
-
# ── Trusted-proxy
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
|
|
56
|
+
# ── Trusted-proxy allowlist ──────────────────────────────────────────────────
|
|
57
|
+
# The per-IP login limiter this list was born for is ``lattice-auth``'s now, and
|
|
58
|
+
# ``client_ip`` went with it. The list itself is still load-bearing on this
|
|
59
|
+
# side: :func:`latticeai.core.http_origin.peer_may_forward` — and through it the
|
|
60
|
+
# CSRF front-door check — asks ``_peer_is_trusted_proxy`` whether an
|
|
61
|
+
# ``X-Forwarded-Host`` from this peer may be believed. Off loopback the answer
|
|
62
|
+
# is "only for an operator-listed proxy", which is what makes forging a front
|
|
63
|
+
# door from another machine impossible. ``configure_trusted_proxies`` is the one
|
|
64
|
+
# way that list is ever non-empty, so it stays with the guard it feeds.
|
|
62
65
|
_trusted_proxies: List["ipaddress._BaseNetwork"] = []
|
|
63
66
|
|
|
64
67
|
|
|
@@ -96,46 +99,6 @@ def _peer_is_trusted_proxy(peer: str) -> bool:
|
|
|
96
99
|
return any(addr in net for net in _trusted_proxies)
|
|
97
100
|
|
|
98
101
|
|
|
99
|
-
def client_ip(request) -> str:
|
|
100
|
-
peer = request.client.host if request.client else ""
|
|
101
|
-
# Only a trusted proxy's forwarded headers are honoured; otherwise the
|
|
102
|
-
# client-supplied header is ignored so per-IP rate limits cannot be spoofed.
|
|
103
|
-
if _peer_is_trusted_proxy(peer):
|
|
104
|
-
for header in _FORWARDED_HEADERS:
|
|
105
|
-
val = request.headers.get(header)
|
|
106
|
-
if val:
|
|
107
|
-
candidate = val.split(",")[0].strip()
|
|
108
|
-
try:
|
|
109
|
-
ipaddress.ip_address(candidate)
|
|
110
|
-
return candidate
|
|
111
|
-
except ValueError:
|
|
112
|
-
quiet()
|
|
113
|
-
continue
|
|
114
|
-
return peer or "unknown"
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
_FILE_MAGIC: Dict[str, List[bytes]] = {
|
|
118
|
-
".pdf": [b"%PDF-"],
|
|
119
|
-
".docx": [b"PK\x03\x04"],
|
|
120
|
-
".xlsx": [b"PK\x03\x04"],
|
|
121
|
-
".pptx": [b"PK\x03\x04"],
|
|
122
|
-
".zip": [b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"],
|
|
123
|
-
".png": [b"\x89PNG\r\n\x1a\n"],
|
|
124
|
-
".jpg": [b"\xff\xd8\xff"],
|
|
125
|
-
".jpeg": [b"\xff\xd8\xff"],
|
|
126
|
-
".gif": [b"GIF87a", b"GIF89a"],
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def bytes_match_extension(data: bytes, ext: str) -> bool:
|
|
131
|
-
ext = (ext or "").lower()
|
|
132
|
-
signatures = _FILE_MAGIC.get(ext)
|
|
133
|
-
if not signatures:
|
|
134
|
-
return True
|
|
135
|
-
head = data[:16]
|
|
136
|
-
return any(head.startswith(sig) for sig in signatures)
|
|
137
|
-
|
|
138
|
-
|
|
139
102
|
SECRET_KEY_HINTS = (
|
|
140
103
|
"api_key",
|
|
141
104
|
"apikey",
|
|
@@ -209,23 +172,6 @@ def redact_secrets(value: Any) -> Any:
|
|
|
209
172
|
return value
|
|
210
173
|
|
|
211
174
|
|
|
212
|
-
# ── IP-based rate limiting (registration / login) ────────────────────────────
|
|
213
|
-
_ip_rate_windows: dict = {}
|
|
214
|
-
_ip_rate_lock = threading.Lock()
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def check_ip_rate_limit(ip: str, action: str, max_calls: int, window_secs: float) -> None:
|
|
218
|
-
key = (ip, action)
|
|
219
|
-
now = time.time()
|
|
220
|
-
cutoff = now - window_secs
|
|
221
|
-
with _ip_rate_lock:
|
|
222
|
-
calls = [t for t in _ip_rate_windows.get(key, []) if t > cutoff]
|
|
223
|
-
if len(calls) >= max_calls:
|
|
224
|
-
raise HTTPException(status_code=429, detail="요청이 너무 많습니다. 잠시 후 다시 시도하세요.")
|
|
225
|
-
calls.append(now)
|
|
226
|
-
_ip_rate_windows[key] = calls
|
|
227
|
-
|
|
228
|
-
|
|
229
175
|
# ── Per-user token-bucket rate limiting ──────────────────────────────────────
|
|
230
176
|
_RATE_LIMITS = {
|
|
231
177
|
"chat": (30, 0.5),
|
|
@@ -4,6 +4,21 @@ v4: bearer tokens are stored **hashed** (sha256) at rest — a process that can
|
|
|
4
4
|
read ``sessions.json`` must not be able to hijack every session. Pre-v4 files
|
|
5
5
|
holding raw tokens are migrated transparently on load (sessions survive the
|
|
6
6
|
upgrade; the raw token never touches disk again).
|
|
7
|
+
|
|
8
|
+
11.8.0: the in-memory map is a **cache of the file, not the file itself.**
|
|
9
|
+
Since v11.6.0 the writer is ``lattice-auth``: it creates the session and
|
|
10
|
+
appends it to ``sessions.json``, and this process only ever reads. Loading
|
|
11
|
+
once in ``__init__`` therefore made every login that happened after worker
|
|
12
|
+
boot invisible here — silently under ``trusted_local_owner`` (the anonymous
|
|
13
|
+
owner path answers first), and as a flat 401 under
|
|
14
|
+
``LATTICEAI_REQUIRE_AUTH=true``, for a token that is sitting in the file.
|
|
15
|
+
|
|
16
|
+
A lookup that misses now re-reads the file before giving up. Two guards keep
|
|
17
|
+
that from turning a token-guessing burst into a disk-read burst: the re-read
|
|
18
|
+
is skipped when the file's ``mtime_ns``/``size`` are unchanged since the last
|
|
19
|
+
load, and — for a file that *is* changing — throttled to one parse per
|
|
20
|
+
``SESSION_RELOAD_MIN_INTERVAL``. Both are checked under the same lock that
|
|
21
|
+
guards the map, so concurrent misses collapse into one read.
|
|
7
22
|
"""
|
|
8
23
|
|
|
9
24
|
import json
|
|
@@ -19,6 +34,11 @@ from latticeai.core.security import sha256_hex
|
|
|
19
34
|
|
|
20
35
|
SESSION_TTL = 60 * 60 * 24 # 24 hours
|
|
21
36
|
SESSION_REFRESH_THRESHOLD = 60 * 15 # only persist if >15 min since last bump
|
|
37
|
+
#: Floor between two miss-triggered re-reads of ``sessions.json``. A wrong or
|
|
38
|
+
#: expired token is the common case for an unauthenticated probe, and each one
|
|
39
|
+
#: misses; without a floor a burst of them would be a burst of file reads.
|
|
40
|
+
#: Well under any human's retry, so a real login is still picked up at once.
|
|
41
|
+
SESSION_RELOAD_MIN_INTERVAL = 1.0
|
|
22
42
|
_lock = threading.Lock()
|
|
23
43
|
|
|
24
44
|
_HEX64 = frozenset("0123456789abcdef")
|
|
@@ -79,6 +99,19 @@ def persist_sessions(sessions: Dict[str, tuple], data_dir: Optional[Path] = None
|
|
|
79
99
|
logging.warning("persist_sessions failed: %s", e)
|
|
80
100
|
|
|
81
101
|
|
|
102
|
+
def _sessions_stamp(data_dir: Optional[Path] = None) -> Optional[tuple]:
|
|
103
|
+
"""``(mtime_ns, size)`` of ``sessions.json``, or ``None`` when unreadable.
|
|
104
|
+
|
|
105
|
+
``None`` is *not* "unchanged": a file that has just appeared and one that
|
|
106
|
+
was just removed both need the map rebuilt, and both land here.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
stat = _sessions_file(data_dir).stat()
|
|
110
|
+
except OSError:
|
|
111
|
+
return None
|
|
112
|
+
return (stat.st_mtime_ns, stat.st_size)
|
|
113
|
+
|
|
114
|
+
|
|
82
115
|
def _entry_subject(entry: tuple) -> Optional[str]:
|
|
83
116
|
return entry[0] if entry else None
|
|
84
117
|
|
|
@@ -107,12 +140,63 @@ class SessionStore:
|
|
|
107
140
|
self._ttl_seconds = int(ttl_seconds or SESSION_TTL)
|
|
108
141
|
self._refresh_threshold_seconds = int(refresh_threshold_seconds or SESSION_REFRESH_THRESHOLD)
|
|
109
142
|
self._sessions: Dict[str, tuple] = load_sessions(data_dir)
|
|
143
|
+
# Stamped *after* the load, so a pre-v4 file that ``load_sessions``
|
|
144
|
+
# rewrote on the way in is not mistaken for a foreign write.
|
|
145
|
+
self._loaded_stamp: Optional[tuple] = _sessions_stamp(data_dir)
|
|
146
|
+
# ``-inf`` and not "now": the first *changed* file must be free to
|
|
147
|
+
# load, or a login that lands in the same second as the worker's boot
|
|
148
|
+
# stays invisible for a second for no reason.
|
|
149
|
+
self._last_reload_at: float = float("-inf")
|
|
150
|
+
|
|
151
|
+
def _reload_if_stale(self, *, force: bool = False) -> bool:
|
|
152
|
+
"""Re-read ``sessions.json`` when it has changed. Caller holds ``_lock``.
|
|
153
|
+
|
|
154
|
+
Returns whether the map was replaced. The two guards answer two
|
|
155
|
+
different costs, in the order that keeps both cheap:
|
|
156
|
+
|
|
157
|
+
* the **stamp** runs first and on every miss. It is one ``stat``, and
|
|
158
|
+
when the file has not moved — the overwhelming case, since a wrong
|
|
159
|
+
token is what an unauthenticated probe sends — that is the entire
|
|
160
|
+
cost. No parse, no allocation.
|
|
161
|
+
* the **interval** runs only once the file *has* moved, and bounds how
|
|
162
|
+
often a busy file is actually parsed. It never drops the change: the
|
|
163
|
+
stamp is recorded only when the load really happened, so the next
|
|
164
|
+
miss past the interval still sees the file as new.
|
|
165
|
+
|
|
166
|
+
``force`` skips the interval only. It is for the two paths that are
|
|
167
|
+
about to *write* the file: a throttled read there would merge onto a
|
|
168
|
+
stale map and drop somebody else's session, and a login or a logout is
|
|
169
|
+
rare enough that the read costs nothing worth counting.
|
|
170
|
+
"""
|
|
171
|
+
stamp = _sessions_stamp(self._data_dir)
|
|
172
|
+
if stamp == self._loaded_stamp:
|
|
173
|
+
return False
|
|
174
|
+
now = time.monotonic()
|
|
175
|
+
if not force and now - self._last_reload_at < SESSION_RELOAD_MIN_INTERVAL:
|
|
176
|
+
return False
|
|
177
|
+
self._last_reload_at = now
|
|
178
|
+
self._sessions = load_sessions(self._data_dir)
|
|
179
|
+
self._loaded_stamp = stamp
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
def _persist(self) -> None:
|
|
183
|
+
"""Write the map, then re-stamp. Caller holds ``_lock``.
|
|
184
|
+
|
|
185
|
+
Re-stamping matters: without it our own write looks like somebody
|
|
186
|
+
else's the next time a lookup misses, and the store re-reads the file
|
|
187
|
+
it just produced.
|
|
188
|
+
"""
|
|
189
|
+
persist_sessions(self._sessions, self._data_dir)
|
|
190
|
+
self._loaded_stamp = _sessions_stamp(self._data_dir)
|
|
110
191
|
|
|
111
192
|
def create(self, subject: str, *, email: Optional[str] = None) -> str:
|
|
112
193
|
token = secrets.token_urlsafe(32)
|
|
113
194
|
with _lock:
|
|
195
|
+
# Merge onto whatever is on disk rather than over it — this process
|
|
196
|
+
# is not the only writer.
|
|
197
|
+
self._reload_if_stale(force=True)
|
|
114
198
|
self._sessions[_hash_token(token)] = (subject, time.time(), email or subject)
|
|
115
|
-
|
|
199
|
+
self._persist()
|
|
116
200
|
return token
|
|
117
201
|
|
|
118
202
|
def get_email(self, token: str) -> Optional[str]:
|
|
@@ -128,21 +212,28 @@ class SessionStore:
|
|
|
128
212
|
key = _hash_token(token)
|
|
129
213
|
with _lock:
|
|
130
214
|
entry = self._sessions.get(key)
|
|
215
|
+
if entry is None and self._reload_if_stale():
|
|
216
|
+
# A miss is the only thing that can be wrong because the map is
|
|
217
|
+
# stale: an entry we *do* hold was really issued, and one we
|
|
218
|
+
# hold that the file no longer has is handled by the TTL and by
|
|
219
|
+
# ``active_session_email``'s account check on every request.
|
|
220
|
+
entry = self._sessions.get(key)
|
|
131
221
|
if entry is None:
|
|
132
222
|
return None
|
|
133
223
|
created_at = _entry_created_at(entry)
|
|
134
224
|
if now - created_at > self._ttl_seconds:
|
|
135
225
|
self._sessions.pop(key, None)
|
|
136
|
-
|
|
226
|
+
self._persist()
|
|
137
227
|
return None
|
|
138
228
|
if now - created_at > self._refresh_threshold_seconds:
|
|
139
229
|
refreshed = (_entry_subject(entry), now, _entry_email(entry))
|
|
140
230
|
self._sessions[key] = refreshed
|
|
141
|
-
|
|
231
|
+
self._persist()
|
|
142
232
|
return refreshed
|
|
143
233
|
return entry
|
|
144
234
|
|
|
145
235
|
def invalidate(self, token: str) -> None:
|
|
146
236
|
with _lock:
|
|
237
|
+
self._reload_if_stale(force=True)
|
|
147
238
|
self._sessions.pop(_hash_token(token), None)
|
|
148
|
-
|
|
239
|
+
self._persist()
|
package/latticeai/core/users.py
CHANGED
|
@@ -4,9 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import shutil
|
|
7
|
-
import sqlite3
|
|
8
7
|
import uuid
|
|
9
|
-
from contextlib import closing
|
|
10
8
|
from pathlib import Path
|
|
11
9
|
from typing import Any, Dict, Optional
|
|
12
10
|
|
|
@@ -83,11 +81,6 @@ def load_users_file(path: Path) -> Dict[str, Any]:
|
|
|
83
81
|
return migrated
|
|
84
82
|
|
|
85
83
|
|
|
86
|
-
def save_users_file(path: Path, users: Dict[str, Any]) -> None:
|
|
87
|
-
migrated, _, _ = migrate_users(users)
|
|
88
|
-
atomic_write_json(path, migrated)
|
|
89
|
-
|
|
90
|
-
|
|
91
84
|
def user_id_for_email(users: Dict[str, Any], email: Optional[str]) -> Optional[str]:
|
|
92
85
|
if not email:
|
|
93
86
|
return None
|
|
@@ -98,34 +91,3 @@ def user_id_for_email(users: Dict[str, Any], email: Optional[str]) -> Optional[s
|
|
|
98
91
|
if isinstance(user, dict):
|
|
99
92
|
return user.get("id") or stable_user_id(normalized)
|
|
100
93
|
return stable_user_id(normalized)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def migrate_knowledge_graph_identity(db_path: Path, email_to_id: Dict[str, str]) -> int:
|
|
104
|
-
"""Rewrite KG owner/creator identity columns from email to stable UUIDs."""
|
|
105
|
-
if not db_path.exists() or not email_to_id:
|
|
106
|
-
return 0
|
|
107
|
-
changed = 0
|
|
108
|
-
with closing(sqlite3.connect(db_path)) as conn, conn:
|
|
109
|
-
tables = {
|
|
110
|
-
row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
111
|
-
}
|
|
112
|
-
for email, user_id in email_to_id.items():
|
|
113
|
-
normalized = normalize_email(email)
|
|
114
|
-
if "nodes_v2" in tables:
|
|
115
|
-
cur = conn.execute("UPDATE nodes_v2 SET owner_id=? WHERE LOWER(owner_id)=?", (user_id, normalized))
|
|
116
|
-
changed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
|
|
117
|
-
if "edges_v2" in tables:
|
|
118
|
-
cur = conn.execute("UPDATE edges_v2 SET created_by=? WHERE LOWER(created_by)=?", (user_id, normalized))
|
|
119
|
-
changed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
|
|
120
|
-
if "ingestion_provenance" in tables:
|
|
121
|
-
cur = conn.execute("UPDATE ingestion_provenance SET owner=? WHERE LOWER(owner)=?", (user_id, normalized))
|
|
122
|
-
changed += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
|
|
123
|
-
if changed:
|
|
124
|
-
conn.execute(
|
|
125
|
-
"CREATE TABLE IF NOT EXISTS kg_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
|
126
|
-
)
|
|
127
|
-
conn.execute(
|
|
128
|
-
"INSERT OR REPLACE INTO kg_meta(key, value) VALUES('identity_uuid_migrated_at', ?)",
|
|
129
|
-
(_now(),),
|
|
130
|
-
)
|
|
131
|
-
return changed
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Search-side vector index backends (HNSW append + query).
|
|
2
|
+
|
|
3
|
+
``POST /worker/vector/query`` serves this sidecar. ``lattice-retrieval``
|
|
4
|
+
asks for top-k candidates when ``LATTICEAI_VECTOR_INDEX=hnsw`` and
|
|
5
|
+
re-scores them exactly. Default env stays the brute scan.
|
|
6
|
+
|
|
7
|
+
This package is the Python HNSW sidecar: a disposable ``.hnsw`` graph
|
|
8
|
+
next to the brain database, with incremental ``add_items`` so a write no
|
|
9
|
+
longer rebuilds the whole graph.
|
|
10
|
+
|
|
11
|
+
Rebuild (not append) when:
|
|
12
|
+
|
|
13
|
+
* the embedder identity (``model_id`` / dim / space) changed
|
|
14
|
+
* any previously indexed id is absent (deletion). Tombstones exist in
|
|
15
|
+
hnswlib (``mark_deleted``) but a deletion-heavy store (more than
|
|
16
|
+
:data:`DELETE_REBUILD_RATIO` of the graph, or any delete when the
|
|
17
|
+
sidecar was loaded without source vectors) is cheaper to rebuild
|
|
18
|
+
than to fragment.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .hnsw import (
|
|
22
|
+
DELETE_REBUILD_RATIO,
|
|
23
|
+
HNSW_BACKEND,
|
|
24
|
+
HNSW_META_SUFFIX,
|
|
25
|
+
HNSW_SUFFIX,
|
|
26
|
+
HNSWLIB_MODULE,
|
|
27
|
+
HnswIndex,
|
|
28
|
+
hnswlib_available,
|
|
29
|
+
load_hnswlib,
|
|
30
|
+
sidecar_paths,
|
|
31
|
+
)
|
|
32
|
+
from .sidecar import (
|
|
33
|
+
GRAPH_DB_NAME,
|
|
34
|
+
VECTOR_QUERY_K_CAP,
|
|
35
|
+
decode_f32le,
|
|
36
|
+
query_sidecar,
|
|
37
|
+
reset_sidecar_cache,
|
|
38
|
+
resolve_graph_db,
|
|
39
|
+
sidecar_fingerprint,
|
|
40
|
+
sidecar_freshness,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"DELETE_REBUILD_RATIO",
|
|
45
|
+
"GRAPH_DB_NAME",
|
|
46
|
+
"HNSW_BACKEND",
|
|
47
|
+
"HNSW_META_SUFFIX",
|
|
48
|
+
"HNSW_SUFFIX",
|
|
49
|
+
"HNSWLIB_MODULE",
|
|
50
|
+
"HnswIndex",
|
|
51
|
+
"VECTOR_QUERY_K_CAP",
|
|
52
|
+
"decode_f32le",
|
|
53
|
+
"hnswlib_available",
|
|
54
|
+
"load_hnswlib",
|
|
55
|
+
"query_sidecar",
|
|
56
|
+
"reset_sidecar_cache",
|
|
57
|
+
"resolve_graph_db",
|
|
58
|
+
"sidecar_fingerprint",
|
|
59
|
+
"sidecar_freshness",
|
|
60
|
+
"sidecar_paths",
|
|
61
|
+
]
|