loki-mode 7.79.0 → 7.80.1
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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +140 -2
- package/autonomy/run.sh +3 -0
- package/dashboard/__init__.py +1 -1
- package/dashboard/registry.py +212 -0
- package/dashboard/server.py +236 -0
- package/dashboard/static/index.html +383 -150
- package/docs/ENTERPRISE-IDENTITY-ROADMAP.md +206 -0
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +2 -2
- package/lokistore/__init__.py +65 -0
- package/lokistore/base.py +172 -0
- package/lokistore/cloud.py +305 -0
- package/lokistore/factory.py +187 -0
- package/lokistore/local.py +219 -0
- package/mcp/__init__.py +1 -1
- package/memory/retrieval.py +147 -0
- package/memory/tree_index.py +499 -0
- package/memory/tree_search.py +305 -0
- package/package.json +2 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Optional cloud LokiStore backends: S3, GCS, Azure Blob.
|
|
3
|
+
|
|
4
|
+
Each backend's SDK is imported ONLY inside its __init__, so:
|
|
5
|
+
- The SDK is never imported at module import time.
|
|
6
|
+
- Local-only users never touch these classes and never pay an import cost.
|
|
7
|
+
- Selecting a cloud backend without its SDK installed raises a clear,
|
|
8
|
+
actionable BackendNotAvailableError naming the missing package.
|
|
9
|
+
|
|
10
|
+
Backends are intentionally thin. They do not add retry/credential frameworks;
|
|
11
|
+
they rely on each SDK's default credential chain (env vars, instance metadata,
|
|
12
|
+
etc.). Keys are normalized identically to LocalStore (traversal rejected) and
|
|
13
|
+
an optional store-level prefix is prepended to every object key.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import List, Optional, Union
|
|
21
|
+
|
|
22
|
+
from .base import (
|
|
23
|
+
BackendNotAvailableError,
|
|
24
|
+
LokiStore,
|
|
25
|
+
normalize_key,
|
|
26
|
+
read_source_bytes,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _join_prefix(prefix: str, key: str) -> str:
|
|
31
|
+
"""Join a normalized store prefix and a normalized key into an object key."""
|
|
32
|
+
key = normalize_key(key)
|
|
33
|
+
if not prefix:
|
|
34
|
+
return key
|
|
35
|
+
return f"{prefix.rstrip('/')}/{key}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _normalize_prefix(prefix: Optional[str]) -> str:
|
|
39
|
+
"""Normalize an optional store prefix to a clean POSIX-style path (or '')."""
|
|
40
|
+
if not prefix:
|
|
41
|
+
return ""
|
|
42
|
+
# Reuse the key guard so a prefix cannot smuggle traversal either.
|
|
43
|
+
return normalize_key(prefix)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _dir_prefix(obj_prefix: str) -> str:
|
|
47
|
+
"""
|
|
48
|
+
Coerce a non-empty object-key prefix to its directory form (slash
|
|
49
|
+
terminated) so cloud list() matches at slash boundaries like LocalStore's
|
|
50
|
+
directory walk, per the LokiStore.list contract. An empty prefix (list
|
|
51
|
+
everything) is returned unchanged.
|
|
52
|
+
"""
|
|
53
|
+
if obj_prefix and not obj_prefix.endswith("/"):
|
|
54
|
+
return obj_prefix + "/"
|
|
55
|
+
return obj_prefix
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class S3Store(LokiStore):
|
|
59
|
+
"""Amazon S3 (or S3-compatible) backend (requires boto3)."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
bucket: str,
|
|
64
|
+
prefix: Optional[str] = None,
|
|
65
|
+
region: Optional[str] = None,
|
|
66
|
+
):
|
|
67
|
+
if not bucket:
|
|
68
|
+
raise ValueError("S3Store requires a bucket name")
|
|
69
|
+
try:
|
|
70
|
+
import boto3 # noqa: F401 (imported here so missing dep is lazy)
|
|
71
|
+
except ImportError as exc:
|
|
72
|
+
raise BackendNotAvailableError(
|
|
73
|
+
"The s3 storage backend requires the 'boto3' package. "
|
|
74
|
+
"Install it with: pip install boto3"
|
|
75
|
+
) from exc
|
|
76
|
+
|
|
77
|
+
self._bucket = bucket
|
|
78
|
+
self._prefix = _normalize_prefix(prefix)
|
|
79
|
+
# boto3 picks up credentials from its default chain (env, shared
|
|
80
|
+
# config, instance/role metadata). region_name is optional.
|
|
81
|
+
kwargs = {"region_name": region} if region else {}
|
|
82
|
+
self._client = boto3.client("s3", **kwargs)
|
|
83
|
+
|
|
84
|
+
def _object_key(self, key: str) -> str:
|
|
85
|
+
return _join_prefix(self._prefix, key)
|
|
86
|
+
|
|
87
|
+
def put(self, key: str, data: Union[bytes, bytearray, str, os.PathLike]) -> None:
|
|
88
|
+
payload = read_source_bytes(data)
|
|
89
|
+
self._client.put_object(
|
|
90
|
+
Bucket=self._bucket, Key=self._object_key(key), Body=payload
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def get(self, key: str) -> bytes:
|
|
94
|
+
try:
|
|
95
|
+
resp = self._client.get_object(
|
|
96
|
+
Bucket=self._bucket, Key=self._object_key(key)
|
|
97
|
+
)
|
|
98
|
+
except self._client.exceptions.NoSuchKey as exc:
|
|
99
|
+
raise FileNotFoundError(f"no such key: {key!r}") from exc
|
|
100
|
+
return resp["Body"].read()
|
|
101
|
+
|
|
102
|
+
def get_to(self, key: str, dest_path: Union[str, os.PathLike]) -> None:
|
|
103
|
+
dest = Path(dest_path).expanduser()
|
|
104
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
self._client.download_file(self._bucket, self._object_key(key), str(dest))
|
|
106
|
+
|
|
107
|
+
def exists(self, key: str) -> bool:
|
|
108
|
+
try:
|
|
109
|
+
self._client.head_object(Bucket=self._bucket, Key=self._object_key(key))
|
|
110
|
+
return True
|
|
111
|
+
except Exception:
|
|
112
|
+
# head_object raises ClientError (404) when absent; treat any
|
|
113
|
+
# lookup failure as "not present" for the boolean contract.
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
def list(self, prefix: str = "") -> List[str]:
|
|
117
|
+
# A caller prefix is matched at slash boundaries (directory form); the
|
|
118
|
+
# store-level prefix, when listing everything, is left as-is.
|
|
119
|
+
obj_prefix = _dir_prefix(self._object_key(prefix)) if prefix else self._prefix
|
|
120
|
+
paginator = self._client.get_paginator("list_objects_v2")
|
|
121
|
+
strip = (self._prefix + "/") if self._prefix else ""
|
|
122
|
+
keys: List[str] = []
|
|
123
|
+
for page in paginator.paginate(Bucket=self._bucket, Prefix=obj_prefix):
|
|
124
|
+
for item in page.get("Contents", []):
|
|
125
|
+
full = item["Key"]
|
|
126
|
+
rel = full[len(strip):] if strip and full.startswith(strip) else full
|
|
127
|
+
keys.append(rel)
|
|
128
|
+
keys.sort()
|
|
129
|
+
return keys
|
|
130
|
+
|
|
131
|
+
def delete(self, key: str) -> bool:
|
|
132
|
+
# S3 delete is idempotent and does not signal whether an object
|
|
133
|
+
# existed. Probe first so the boolean contract is honest.
|
|
134
|
+
existed = self.exists(key)
|
|
135
|
+
self._client.delete_object(Bucket=self._bucket, Key=self._object_key(key))
|
|
136
|
+
return existed
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class GCSStore(LokiStore):
|
|
140
|
+
"""Google Cloud Storage backend (requires google-cloud-storage)."""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
bucket: str,
|
|
145
|
+
prefix: Optional[str] = None,
|
|
146
|
+
region: Optional[str] = None, # accepted for signature parity, unused
|
|
147
|
+
):
|
|
148
|
+
if not bucket:
|
|
149
|
+
raise ValueError("GCSStore requires a bucket name")
|
|
150
|
+
try:
|
|
151
|
+
from google.cloud import storage # type: ignore
|
|
152
|
+
except ImportError as exc:
|
|
153
|
+
raise BackendNotAvailableError(
|
|
154
|
+
"The gcs storage backend requires the 'google-cloud-storage' "
|
|
155
|
+
"package. Install it with: pip install google-cloud-storage"
|
|
156
|
+
) from exc
|
|
157
|
+
|
|
158
|
+
self._prefix = _normalize_prefix(prefix)
|
|
159
|
+
# The client uses Application Default Credentials (env, gcloud, or
|
|
160
|
+
# workload identity).
|
|
161
|
+
self._client = storage.Client()
|
|
162
|
+
self._bucket = self._client.bucket(bucket)
|
|
163
|
+
|
|
164
|
+
def _object_key(self, key: str) -> str:
|
|
165
|
+
return _join_prefix(self._prefix, key)
|
|
166
|
+
|
|
167
|
+
def put(self, key: str, data: Union[bytes, bytearray, str, os.PathLike]) -> None:
|
|
168
|
+
payload = read_source_bytes(data)
|
|
169
|
+
blob = self._bucket.blob(self._object_key(key))
|
|
170
|
+
blob.upload_from_string(payload)
|
|
171
|
+
|
|
172
|
+
def get(self, key: str) -> bytes:
|
|
173
|
+
blob = self._bucket.blob(self._object_key(key))
|
|
174
|
+
if not blob.exists():
|
|
175
|
+
raise FileNotFoundError(f"no such key: {key!r}")
|
|
176
|
+
return blob.download_as_bytes()
|
|
177
|
+
|
|
178
|
+
def get_to(self, key: str, dest_path: Union[str, os.PathLike]) -> None:
|
|
179
|
+
dest = Path(dest_path).expanduser()
|
|
180
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
blob = self._bucket.blob(self._object_key(key))
|
|
182
|
+
if not blob.exists():
|
|
183
|
+
raise FileNotFoundError(f"no such key: {key!r}")
|
|
184
|
+
blob.download_to_filename(str(dest))
|
|
185
|
+
|
|
186
|
+
def exists(self, key: str) -> bool:
|
|
187
|
+
return self._bucket.blob(self._object_key(key)).exists()
|
|
188
|
+
|
|
189
|
+
def list(self, prefix: str = "") -> List[str]:
|
|
190
|
+
obj_prefix = _dir_prefix(self._object_key(prefix)) if prefix else self._prefix
|
|
191
|
+
strip = (self._prefix + "/") if self._prefix else ""
|
|
192
|
+
keys: List[str] = []
|
|
193
|
+
for blob in self._client.list_blobs(self._bucket, prefix=obj_prefix):
|
|
194
|
+
full = blob.name
|
|
195
|
+
rel = full[len(strip):] if strip and full.startswith(strip) else full
|
|
196
|
+
keys.append(rel)
|
|
197
|
+
keys.sort()
|
|
198
|
+
return keys
|
|
199
|
+
|
|
200
|
+
def delete(self, key: str) -> bool:
|
|
201
|
+
blob = self._bucket.blob(self._object_key(key))
|
|
202
|
+
if not blob.exists():
|
|
203
|
+
return False
|
|
204
|
+
blob.delete()
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class AzureBlobStore(LokiStore):
|
|
209
|
+
"""Azure Blob Storage backend (requires azure-storage-blob)."""
|
|
210
|
+
|
|
211
|
+
def __init__(
|
|
212
|
+
self,
|
|
213
|
+
bucket: str,
|
|
214
|
+
prefix: Optional[str] = None,
|
|
215
|
+
region: Optional[str] = None, # accepted for signature parity, unused
|
|
216
|
+
):
|
|
217
|
+
# In Azure terms `bucket` is the container name.
|
|
218
|
+
if not bucket:
|
|
219
|
+
raise ValueError("AzureBlobStore requires a container name (bucket)")
|
|
220
|
+
try:
|
|
221
|
+
from azure.storage.blob import ContainerClient # type: ignore
|
|
222
|
+
except ImportError as exc:
|
|
223
|
+
raise BackendNotAvailableError(
|
|
224
|
+
"The azure-blob storage backend requires the "
|
|
225
|
+
"'azure-storage-blob' package. "
|
|
226
|
+
"Install it with: pip install azure-storage-blob"
|
|
227
|
+
) from exc
|
|
228
|
+
|
|
229
|
+
self._prefix = _normalize_prefix(prefix)
|
|
230
|
+
|
|
231
|
+
# Credentials come from the SDK's default chain. We accept either a
|
|
232
|
+
# full connection string (AZURE_STORAGE_CONNECTION_STRING) or an
|
|
233
|
+
# account URL (AZURE_STORAGE_ACCOUNT_URL) + DefaultAzureCredential.
|
|
234
|
+
conn = os.environ.get("AZURE_STORAGE_CONNECTION_STRING")
|
|
235
|
+
account_url = os.environ.get("AZURE_STORAGE_ACCOUNT_URL")
|
|
236
|
+
if conn:
|
|
237
|
+
self._container = ContainerClient.from_connection_string(
|
|
238
|
+
conn, container_name=bucket
|
|
239
|
+
)
|
|
240
|
+
elif account_url:
|
|
241
|
+
try:
|
|
242
|
+
from azure.identity import DefaultAzureCredential # type: ignore
|
|
243
|
+
except ImportError as exc:
|
|
244
|
+
raise BackendNotAvailableError(
|
|
245
|
+
"Azure account-URL auth requires the 'azure-identity' "
|
|
246
|
+
"package. Install it with: pip install azure-identity"
|
|
247
|
+
) from exc
|
|
248
|
+
self._container = ContainerClient(
|
|
249
|
+
account_url=account_url,
|
|
250
|
+
container_name=bucket,
|
|
251
|
+
credential=DefaultAzureCredential(),
|
|
252
|
+
)
|
|
253
|
+
else:
|
|
254
|
+
raise BackendNotAvailableError(
|
|
255
|
+
"The azure-blob backend needs AZURE_STORAGE_CONNECTION_STRING "
|
|
256
|
+
"or AZURE_STORAGE_ACCOUNT_URL to be set."
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def _object_key(self, key: str) -> str:
|
|
260
|
+
return _join_prefix(self._prefix, key)
|
|
261
|
+
|
|
262
|
+
def put(self, key: str, data: Union[bytes, bytearray, str, os.PathLike]) -> None:
|
|
263
|
+
payload = read_source_bytes(data)
|
|
264
|
+
self._container.upload_blob(
|
|
265
|
+
name=self._object_key(key), data=payload, overwrite=True
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def get(self, key: str) -> bytes:
|
|
269
|
+
from azure.core.exceptions import ResourceNotFoundError # type: ignore
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
downloader = self._container.download_blob(self._object_key(key))
|
|
273
|
+
except ResourceNotFoundError as exc:
|
|
274
|
+
raise FileNotFoundError(f"no such key: {key!r}") from exc
|
|
275
|
+
return downloader.readall()
|
|
276
|
+
|
|
277
|
+
def get_to(self, key: str, dest_path: Union[str, os.PathLike]) -> None:
|
|
278
|
+
payload = self.get(key)
|
|
279
|
+
dest = Path(dest_path).expanduser()
|
|
280
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
281
|
+
with open(dest, "wb") as f:
|
|
282
|
+
f.write(payload)
|
|
283
|
+
|
|
284
|
+
def exists(self, key: str) -> bool:
|
|
285
|
+
return self._container.get_blob_client(self._object_key(key)).exists()
|
|
286
|
+
|
|
287
|
+
def list(self, prefix: str = "") -> List[str]:
|
|
288
|
+
obj_prefix = _dir_prefix(self._object_key(prefix)) if prefix else self._prefix
|
|
289
|
+
strip = (self._prefix + "/") if self._prefix else ""
|
|
290
|
+
keys: List[str] = []
|
|
291
|
+
for blob in self._container.list_blobs(name_starts_with=obj_prefix or None):
|
|
292
|
+
full = blob.name
|
|
293
|
+
rel = full[len(strip):] if strip and full.startswith(strip) else full
|
|
294
|
+
keys.append(rel)
|
|
295
|
+
keys.sort()
|
|
296
|
+
return keys
|
|
297
|
+
|
|
298
|
+
def delete(self, key: str) -> bool:
|
|
299
|
+
from azure.core.exceptions import ResourceNotFoundError # type: ignore
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
self._container.delete_blob(self._object_key(key))
|
|
303
|
+
return True
|
|
304
|
+
except ResourceNotFoundError:
|
|
305
|
+
return False
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LokiStore factory: select and construct a backend from config or env.
|
|
3
|
+
|
|
4
|
+
Defaults to local. With nothing configured, get_store() returns a LocalStore
|
|
5
|
+
rooted at the project `.loki/` directory, honoring LOKI_DIR / TARGET_DIR
|
|
6
|
+
exactly like the rest of the codebase. Cloud backends are constructed only when
|
|
7
|
+
explicitly selected, and only then is their SDK imported.
|
|
8
|
+
|
|
9
|
+
Config precedence: an explicit config dict overrides env, env overrides the
|
|
10
|
+
local default. Recognized config keys mirror the env vars (without the
|
|
11
|
+
LOKI_STORAGE_ prefix): backend, bucket, prefix, region, base_dir.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from typing import Any, Dict, Optional
|
|
18
|
+
|
|
19
|
+
from .base import LokiStore, StoreError
|
|
20
|
+
from .local import LocalStore
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_local_base(explicit_base: Optional[str] = None) -> str:
|
|
24
|
+
"""
|
|
25
|
+
Resolve the local `.loki/` base directory exactly like run.sh and the
|
|
26
|
+
dashboard do.
|
|
27
|
+
|
|
28
|
+
Resolution order, matching autonomy/run.sh
|
|
29
|
+
(`${LOKI_DIR:-${TARGET_DIR:-.}/.loki}`):
|
|
30
|
+
1. explicit_base argument, if given
|
|
31
|
+
2. $LOKI_DIR, if set (used verbatim, like the shell)
|
|
32
|
+
3. $TARGET_DIR/.loki, if TARGET_DIR is set
|
|
33
|
+
4. ./.loki (current working directory)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The resolved base directory as a string path. It is NOT created here;
|
|
37
|
+
LocalStore creates parents lazily on first write.
|
|
38
|
+
"""
|
|
39
|
+
if explicit_base:
|
|
40
|
+
return explicit_base
|
|
41
|
+
loki_dir = os.environ.get("LOKI_DIR")
|
|
42
|
+
if loki_dir:
|
|
43
|
+
return loki_dir
|
|
44
|
+
target_dir = os.environ.get("TARGET_DIR", ".")
|
|
45
|
+
return os.path.join(target_dir, ".loki")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _config_from_env() -> Dict[str, Any]:
|
|
49
|
+
"""Read the LOKI_STORAGE_* env vars into a config dict (omitting unset)."""
|
|
50
|
+
cfg: Dict[str, Any] = {}
|
|
51
|
+
backend = os.environ.get("LOKI_STORAGE_BACKEND")
|
|
52
|
+
if backend:
|
|
53
|
+
cfg["backend"] = backend
|
|
54
|
+
bucket = os.environ.get("LOKI_STORAGE_BUCKET")
|
|
55
|
+
if bucket:
|
|
56
|
+
cfg["bucket"] = bucket
|
|
57
|
+
prefix = os.environ.get("LOKI_STORAGE_PREFIX")
|
|
58
|
+
if prefix:
|
|
59
|
+
cfg["prefix"] = prefix
|
|
60
|
+
region = os.environ.get("LOKI_STORAGE_REGION")
|
|
61
|
+
if region:
|
|
62
|
+
cfg["region"] = region
|
|
63
|
+
return cfg
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_store(config: Optional[Dict[str, Any]] = None) -> LokiStore:
|
|
67
|
+
"""
|
|
68
|
+
Construct a LokiStore from an explicit config dict (no env fallback).
|
|
69
|
+
|
|
70
|
+
Use this when a caller has already assembled config from its own source.
|
|
71
|
+
Prefer get_store() for the standard env-aware path.
|
|
72
|
+
|
|
73
|
+
Recognized keys:
|
|
74
|
+
backend : "local" (default) | "s3" | "gcs" | "azure-blob"
|
|
75
|
+
bucket : bucket/container name (cloud backends)
|
|
76
|
+
prefix : key prefix within the bucket (optional)
|
|
77
|
+
region : region (s3, optional)
|
|
78
|
+
base_dir: local base directory (local backend only; overrides resolution)
|
|
79
|
+
"""
|
|
80
|
+
config = dict(config or {})
|
|
81
|
+
backend = (config.get("backend") or "local").strip().lower()
|
|
82
|
+
|
|
83
|
+
if backend in ("local", "", "file", "filesystem"):
|
|
84
|
+
return LocalStore(resolve_local_base(config.get("base_dir")))
|
|
85
|
+
|
|
86
|
+
bucket = config.get("bucket")
|
|
87
|
+
prefix = config.get("prefix")
|
|
88
|
+
region = config.get("region")
|
|
89
|
+
|
|
90
|
+
if backend in ("s3", "aws", "aws-s3"):
|
|
91
|
+
from .cloud import S3Store
|
|
92
|
+
|
|
93
|
+
return S3Store(bucket=bucket, prefix=prefix, region=region)
|
|
94
|
+
|
|
95
|
+
if backend in ("gcs", "gcp", "google", "google-cloud-storage"):
|
|
96
|
+
from .cloud import GCSStore
|
|
97
|
+
|
|
98
|
+
return GCSStore(bucket=bucket, prefix=prefix, region=region)
|
|
99
|
+
|
|
100
|
+
if backend in ("azure-blob", "azure", "azureblob", "azure_blob"):
|
|
101
|
+
from .cloud import AzureBlobStore
|
|
102
|
+
|
|
103
|
+
return AzureBlobStore(bucket=bucket, prefix=prefix, region=region)
|
|
104
|
+
|
|
105
|
+
raise StoreError(
|
|
106
|
+
f"unknown storage backend: {backend!r} "
|
|
107
|
+
"(expected one of: local, s3, gcs, azure-blob)"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_store(config: Optional[Dict[str, Any]] = None) -> LokiStore:
|
|
112
|
+
"""
|
|
113
|
+
Return a LokiStore, defaulting to local.
|
|
114
|
+
|
|
115
|
+
Resolution:
|
|
116
|
+
1. Start from the LOKI_STORAGE_* env vars.
|
|
117
|
+
2. Overlay any explicit config dict (config keys win over env).
|
|
118
|
+
3. If no backend is selected, default to local.
|
|
119
|
+
|
|
120
|
+
With a clean environment and no config, this returns a LocalStore rooted at
|
|
121
|
+
the project `.loki/` -- zero new deps, zero behavior change.
|
|
122
|
+
"""
|
|
123
|
+
merged = _config_from_env()
|
|
124
|
+
if config:
|
|
125
|
+
merged.update({k: v for k, v in config.items() if v is not None})
|
|
126
|
+
return build_store(merged)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Metadata backend selector (stub for this release).
|
|
131
|
+
#
|
|
132
|
+
# Blobs (state, checkpoints, artifacts) go through LokiStore above. Structured
|
|
133
|
+
# metadata (the dashboard's task/run index) lives in a relational store. Today
|
|
134
|
+
# that is the existing async-SQLite database in dashboard/database.py at
|
|
135
|
+
# $LOKI_DATA_DIR/dashboard.db. A postgres backend can be added later for shared
|
|
136
|
+
# multi-instance fleets; the selector below is the seam for that, defaulting to
|
|
137
|
+
# sqlite so there is no behavior change now.
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def get_metadata_backend(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
141
|
+
"""
|
|
142
|
+
Resolve the metadata (relational) backend selection.
|
|
143
|
+
|
|
144
|
+
This is intentionally a thin, documented descriptor rather than a live
|
|
145
|
+
connection: the existing dashboard/database.py owns the sqlite engine, and
|
|
146
|
+
a postgres implementation is deferred. Returning a descriptor lets future
|
|
147
|
+
callers branch on the choice without this module importing SQLAlchemy or
|
|
148
|
+
any database driver.
|
|
149
|
+
|
|
150
|
+
Selection (config["metadata_backend"] overrides
|
|
151
|
+
$LOKI_METADATA_BACKEND; default "sqlite"):
|
|
152
|
+
sqlite -> reuse $LOKI_DATA_DIR/dashboard.db (default, implemented today
|
|
153
|
+
in dashboard/database.py)
|
|
154
|
+
postgres -> read $LOKI_METADATA_URL (NOT YET IMPLEMENTED; selecting it
|
|
155
|
+
returns the descriptor with implemented=False so callers can
|
|
156
|
+
fail with a clear message until the impl lands)
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
A descriptor dict: {backend, implemented, dsn, note}.
|
|
160
|
+
"""
|
|
161
|
+
config = dict(config or {})
|
|
162
|
+
backend = (
|
|
163
|
+
config.get("metadata_backend")
|
|
164
|
+
or os.environ.get("LOKI_METADATA_BACKEND")
|
|
165
|
+
or "sqlite"
|
|
166
|
+
).strip().lower()
|
|
167
|
+
|
|
168
|
+
if backend in ("sqlite", "", "default"):
|
|
169
|
+
data_dir = os.environ.get("LOKI_DATA_DIR", os.path.expanduser("~/.loki"))
|
|
170
|
+
return {
|
|
171
|
+
"backend": "sqlite",
|
|
172
|
+
"implemented": True,
|
|
173
|
+
"dsn": os.path.join(data_dir, "dashboard.db"),
|
|
174
|
+
"note": "reuses dashboard/database.py async-sqlite engine",
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if backend in ("postgres", "postgresql", "pg"):
|
|
178
|
+
return {
|
|
179
|
+
"backend": "postgres",
|
|
180
|
+
"implemented": False,
|
|
181
|
+
"dsn": config.get("metadata_url") or os.environ.get("LOKI_METADATA_URL"),
|
|
182
|
+
"note": "postgres metadata backend is planned, not yet implemented",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
raise StoreError(
|
|
186
|
+
f"unknown metadata backend: {backend!r} (expected: sqlite, postgres)"
|
|
187
|
+
)
|