msdev 0.9.0__py3-none-any.whl
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.
- msdev/__init__.py +3 -0
- msdev/cli.py +1460 -0
- msdev/core/__init__.py +1 -0
- msdev/core/config.py +193 -0
- msdev/core/guides.py +100 -0
- msdev/core/inventory.py +1390 -0
- msdev/core/limits.py +44 -0
- msdev/core/resources.py +300 -0
- msdev/core/services/__init__.py +111 -0
- msdev/core/services/context.py +61 -0
- msdev/core/services/environment.py +84 -0
- msdev/core/services/execution.py +136 -0
- msdev/core/services/model.py +1085 -0
- msdev/core/services/node.py +210 -0
- msdev/core/services/npu.py +116 -0
- msdev/core/services/workspace.py +567 -0
- msdev/core/transport.py +1225 -0
- msdev/core/workspace/__init__.py +36 -0
- msdev/core/workspace/access.py +1216 -0
- msdev/core/workspace/client.py +274 -0
- msdev/core/workspace/paths.py +45 -0
- msdev/core/workspace/registry.py +151 -0
- msdev/daemon.py +1322 -0
- msdev/session/__init__.py +13 -0
- msdev/session/export.py +190 -0
- msdev/session/log.py +269 -0
- msdev-0.9.0.dist-info/METADATA +295 -0
- msdev-0.9.0.dist-info/RECORD +31 -0
- msdev-0.9.0.dist-info/WHEEL +5 -0
- msdev-0.9.0.dist-info/entry_points.txt +3 -0
- msdev-0.9.0.dist-info/top_level.txt +1 -0
msdev/core/inventory.py
ADDED
|
@@ -0,0 +1,1390 @@
|
|
|
1
|
+
"""Node-local model artifact and replica inventory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import socket
|
|
10
|
+
import sqlite3
|
|
11
|
+
import uuid
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Iterable, Iterator
|
|
16
|
+
|
|
17
|
+
from .limits import (
|
|
18
|
+
MAX_INVENTORY_ALIASES_PER_ARTIFACT,
|
|
19
|
+
MAX_INVENTORY_ARTIFACTS,
|
|
20
|
+
MAX_INVENTORY_CONTENT_BYTES,
|
|
21
|
+
MAX_INVENTORY_REPLICAS_PER_ARTIFACT,
|
|
22
|
+
MAX_INVENTORY_TAGS_PER_ARTIFACT,
|
|
23
|
+
MAX_INVENTORY_TOTAL_ALIASES,
|
|
24
|
+
MAX_INVENTORY_TOTAL_REPLICAS,
|
|
25
|
+
MAX_INVENTORY_TOTAL_TAGS,
|
|
26
|
+
MAX_ROOT_MAP_PATH_BYTES,
|
|
27
|
+
MAX_ROOT_MAPS,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
SCHEMA_VERSION = 1
|
|
32
|
+
_DISCOVERY_SKIP_DIRS = {
|
|
33
|
+
".git",
|
|
34
|
+
"__pycache__",
|
|
35
|
+
"node_modules",
|
|
36
|
+
"site-packages",
|
|
37
|
+
"venv",
|
|
38
|
+
".venv",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _utc_now() -> str:
|
|
43
|
+
return datetime.now(timezone.utc).isoformat()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Inventory:
|
|
47
|
+
"""SQLite inventory owned by one user-level daemon."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, data_dir: Path):
|
|
50
|
+
self.data_dir = data_dir.expanduser()
|
|
51
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
self.db_path = self.data_dir / "inventory.db"
|
|
53
|
+
self.node_id = self._load_node_id()
|
|
54
|
+
self._initialize()
|
|
55
|
+
|
|
56
|
+
def _load_node_id(self) -> str:
|
|
57
|
+
path = self.data_dir / "node-id"
|
|
58
|
+
if path.exists():
|
|
59
|
+
value = path.read_text(encoding="utf-8").strip()
|
|
60
|
+
if value:
|
|
61
|
+
return value
|
|
62
|
+
value = f"{socket.gethostname()}-{uuid.uuid4().hex[:12]}"
|
|
63
|
+
path.write_text(value + "\n", encoding="utf-8")
|
|
64
|
+
os.chmod(path, 0o600)
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
def _connect(self) -> sqlite3.Connection:
|
|
68
|
+
conn = sqlite3.connect(self.db_path)
|
|
69
|
+
conn.row_factory = sqlite3.Row
|
|
70
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
71
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
72
|
+
return conn
|
|
73
|
+
|
|
74
|
+
@contextmanager
|
|
75
|
+
def _connection(self) -> Iterator[sqlite3.Connection]:
|
|
76
|
+
conn = self._connect()
|
|
77
|
+
try:
|
|
78
|
+
with conn:
|
|
79
|
+
yield conn
|
|
80
|
+
finally:
|
|
81
|
+
conn.close()
|
|
82
|
+
|
|
83
|
+
def _initialize(self) -> None:
|
|
84
|
+
with self._connection() as conn:
|
|
85
|
+
conn.executescript(
|
|
86
|
+
"""
|
|
87
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
88
|
+
id INTEGER PRIMARY KEY,
|
|
89
|
+
ref TEXT NOT NULL UNIQUE,
|
|
90
|
+
revision TEXT,
|
|
91
|
+
variant TEXT,
|
|
92
|
+
format TEXT,
|
|
93
|
+
manifest_digest TEXT,
|
|
94
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
95
|
+
created_at TEXT NOT NULL,
|
|
96
|
+
updated_at TEXT NOT NULL
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
CREATE TABLE IF NOT EXISTS replicas (
|
|
100
|
+
id INTEGER PRIMARY KEY,
|
|
101
|
+
artifact_id INTEGER NOT NULL REFERENCES artifacts(id) ON DELETE CASCADE,
|
|
102
|
+
node_id TEXT NOT NULL,
|
|
103
|
+
path TEXT NOT NULL,
|
|
104
|
+
scope TEXT NOT NULL,
|
|
105
|
+
management TEXT NOT NULL,
|
|
106
|
+
state TEXT NOT NULL,
|
|
107
|
+
verification_level TEXT NOT NULL,
|
|
108
|
+
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
109
|
+
file_count INTEGER NOT NULL DEFAULT 0,
|
|
110
|
+
last_seen TEXT NOT NULL,
|
|
111
|
+
UNIQUE(node_id, path)
|
|
112
|
+
);
|
|
113
|
+
"""
|
|
114
|
+
)
|
|
115
|
+
artifact_columns = {
|
|
116
|
+
row["name"]
|
|
117
|
+
for row in conn.execute("PRAGMA table_info(artifacts)").fetchall()
|
|
118
|
+
}
|
|
119
|
+
if "description" not in artifact_columns:
|
|
120
|
+
conn.execute("ALTER TABLE artifacts ADD COLUMN description TEXT")
|
|
121
|
+
if "deleted_at" not in artifact_columns:
|
|
122
|
+
conn.execute("ALTER TABLE artifacts ADD COLUMN deleted_at TEXT")
|
|
123
|
+
conn.executescript(
|
|
124
|
+
"""
|
|
125
|
+
CREATE TABLE IF NOT EXISTS artifact_aliases (
|
|
126
|
+
artifact_id INTEGER NOT NULL REFERENCES artifacts(id) ON DELETE CASCADE,
|
|
127
|
+
alias TEXT NOT NULL UNIQUE,
|
|
128
|
+
PRIMARY KEY (artifact_id, alias)
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
CREATE TABLE IF NOT EXISTS artifact_tags (
|
|
132
|
+
artifact_id INTEGER NOT NULL REFERENCES artifacts(id) ON DELETE CASCADE,
|
|
133
|
+
tag TEXT NOT NULL,
|
|
134
|
+
PRIMARY KEY (artifact_id, tag)
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
138
|
+
id INTEGER PRIMARY KEY,
|
|
139
|
+
action TEXT NOT NULL,
|
|
140
|
+
ref TEXT,
|
|
141
|
+
details_json TEXT NOT NULL DEFAULT '{}',
|
|
142
|
+
created_at TEXT NOT NULL
|
|
143
|
+
);
|
|
144
|
+
"""
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _audit(
|
|
149
|
+
conn: sqlite3.Connection,
|
|
150
|
+
action: str,
|
|
151
|
+
ref: str | None,
|
|
152
|
+
details: dict[str, Any] | None = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
conn.execute(
|
|
155
|
+
"""
|
|
156
|
+
INSERT INTO audit_log (action, ref, details_json, created_at)
|
|
157
|
+
VALUES (?, ?, ?, ?)
|
|
158
|
+
""",
|
|
159
|
+
(
|
|
160
|
+
action,
|
|
161
|
+
ref,
|
|
162
|
+
json.dumps(details or {}, ensure_ascii=False, sort_keys=True),
|
|
163
|
+
_utc_now(),
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _artifact_row(
|
|
169
|
+
conn: sqlite3.Connection,
|
|
170
|
+
ref_or_alias: str,
|
|
171
|
+
) -> sqlite3.Row:
|
|
172
|
+
row = conn.execute(
|
|
173
|
+
"SELECT * FROM artifacts WHERE ref = ?",
|
|
174
|
+
(ref_or_alias,),
|
|
175
|
+
).fetchone()
|
|
176
|
+
if row is None:
|
|
177
|
+
row = conn.execute(
|
|
178
|
+
"""
|
|
179
|
+
SELECT a.*
|
|
180
|
+
FROM artifacts a
|
|
181
|
+
JOIN artifact_aliases x ON x.artifact_id = a.id
|
|
182
|
+
WHERE x.alias = ?
|
|
183
|
+
""",
|
|
184
|
+
(ref_or_alias,),
|
|
185
|
+
).fetchone()
|
|
186
|
+
if row is None:
|
|
187
|
+
raise KeyError(f"unknown model: {ref_or_alias}")
|
|
188
|
+
return row
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _file_digest(path: Path, hasher: Any) -> None:
|
|
192
|
+
with path.open("rb") as handle:
|
|
193
|
+
while chunk := handle.read(8 * 1024 * 1024):
|
|
194
|
+
hasher.update(chunk)
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def scan_path(cls, path: Path, full_checksum: bool = False) -> dict[str, Any]:
|
|
198
|
+
resolved = path.expanduser().resolve(strict=True)
|
|
199
|
+
files: list[tuple[str, Path]] = []
|
|
200
|
+
if resolved.is_file():
|
|
201
|
+
files.append((resolved.name, resolved))
|
|
202
|
+
elif resolved.is_dir():
|
|
203
|
+
for child in resolved.rglob("*"):
|
|
204
|
+
if child.is_file():
|
|
205
|
+
files.append((child.relative_to(resolved).as_posix(), child))
|
|
206
|
+
else:
|
|
207
|
+
raise ValueError(f"model path must be a file or directory: {resolved}")
|
|
208
|
+
|
|
209
|
+
files.sort(key=lambda item: item[0])
|
|
210
|
+
metadata_hasher = hashlib.sha256()
|
|
211
|
+
content_hasher = hashlib.sha256() if full_checksum else None
|
|
212
|
+
total_size = 0
|
|
213
|
+
for relative, file_path in files:
|
|
214
|
+
stat = file_path.stat()
|
|
215
|
+
total_size += stat.st_size
|
|
216
|
+
metadata_hasher.update(relative.encode("utf-8"))
|
|
217
|
+
metadata_hasher.update(b"\0")
|
|
218
|
+
metadata_hasher.update(str(stat.st_size).encode("ascii"))
|
|
219
|
+
metadata_hasher.update(b"\0")
|
|
220
|
+
metadata_hasher.update(str(stat.st_mtime_ns).encode("ascii"))
|
|
221
|
+
metadata_hasher.update(b"\n")
|
|
222
|
+
if content_hasher is not None:
|
|
223
|
+
content_hasher.update(relative.encode("utf-8"))
|
|
224
|
+
content_hasher.update(b"\0")
|
|
225
|
+
cls._file_digest(file_path, content_hasher)
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
"path": str(resolved),
|
|
229
|
+
"size_bytes": total_size,
|
|
230
|
+
"file_count": len(files),
|
|
231
|
+
"metadata_digest": f"sha256:{metadata_hasher.hexdigest()}",
|
|
232
|
+
"content_digest": f"sha256:{content_hasher.hexdigest()}" if content_hasher else None,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _model_metadata(path: Path) -> tuple[str, dict[str, Any]] | None:
|
|
237
|
+
try:
|
|
238
|
+
files = [item for item in path.iterdir() if item.is_file()]
|
|
239
|
+
except (OSError, PermissionError):
|
|
240
|
+
return None
|
|
241
|
+
names = {item.name for item in files}
|
|
242
|
+
suffixes = {item.suffix.lower() for item in files}
|
|
243
|
+
has_config = "config.json" in names
|
|
244
|
+
has_adapter = "adapter_config.json" in names
|
|
245
|
+
has_safetensors = ".safetensors" in suffixes
|
|
246
|
+
has_bin = ".bin" in suffixes
|
|
247
|
+
has_gguf = ".gguf" in suffixes
|
|
248
|
+
has_index = bool(
|
|
249
|
+
{
|
|
250
|
+
"model.safetensors.index.json",
|
|
251
|
+
"pytorch_model.bin.index.json",
|
|
252
|
+
}
|
|
253
|
+
& names
|
|
254
|
+
)
|
|
255
|
+
if not (
|
|
256
|
+
(has_config and (has_safetensors or has_bin or has_gguf or has_index))
|
|
257
|
+
or (has_adapter and (has_safetensors or has_bin))
|
|
258
|
+
or has_gguf
|
|
259
|
+
):
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
if has_adapter:
|
|
263
|
+
format_name = "adapter-safetensors" if has_safetensors else "adapter-bin"
|
|
264
|
+
elif has_gguf:
|
|
265
|
+
format_name = "gguf"
|
|
266
|
+
elif has_safetensors or "model.safetensors.index.json" in names:
|
|
267
|
+
format_name = "huggingface-safetensors"
|
|
268
|
+
else:
|
|
269
|
+
format_name = "pytorch-bin"
|
|
270
|
+
|
|
271
|
+
config_name = "adapter_config.json" if has_adapter else "config.json"
|
|
272
|
+
config: dict[str, Any] = {}
|
|
273
|
+
config_path = path / config_name
|
|
274
|
+
if config_path.is_file() and config_path.stat().st_size <= 4 * 1024 * 1024:
|
|
275
|
+
try:
|
|
276
|
+
value = json.loads(config_path.read_text(encoding="utf-8"))
|
|
277
|
+
if isinstance(value, dict):
|
|
278
|
+
config = value
|
|
279
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
280
|
+
pass
|
|
281
|
+
return format_name, config
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _portable_structure(path: Path) -> dict[str, Any]:
|
|
285
|
+
hasher = hashlib.sha256()
|
|
286
|
+
total_size = 0
|
|
287
|
+
file_count = 0
|
|
288
|
+
for child in sorted(
|
|
289
|
+
(item for item in path.rglob("*") if item.is_file()),
|
|
290
|
+
key=lambda item: item.relative_to(path).as_posix(),
|
|
291
|
+
):
|
|
292
|
+
try:
|
|
293
|
+
stat = child.stat()
|
|
294
|
+
except (OSError, FileNotFoundError):
|
|
295
|
+
continue
|
|
296
|
+
relative = child.relative_to(path).as_posix()
|
|
297
|
+
total_size += stat.st_size
|
|
298
|
+
file_count += 1
|
|
299
|
+
hasher.update(relative.encode("utf-8"))
|
|
300
|
+
hasher.update(b"\0")
|
|
301
|
+
hasher.update(str(stat.st_size).encode("ascii"))
|
|
302
|
+
hasher.update(b"\n")
|
|
303
|
+
return {
|
|
304
|
+
"structure_digest": f"sha256:{hasher.hexdigest()}",
|
|
305
|
+
"size_bytes": total_size,
|
|
306
|
+
"file_count": file_count,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _discovered_ref(
|
|
311
|
+
root: Path,
|
|
312
|
+
path: Path,
|
|
313
|
+
config: dict[str, Any],
|
|
314
|
+
structure_digest: str,
|
|
315
|
+
namespace: str,
|
|
316
|
+
) -> str:
|
|
317
|
+
configured = config.get("_name_or_path")
|
|
318
|
+
if isinstance(configured, str) and configured and not Path(configured).is_absolute():
|
|
319
|
+
name = configured.removeprefix("model://")
|
|
320
|
+
else:
|
|
321
|
+
try:
|
|
322
|
+
name = path.relative_to(root).as_posix()
|
|
323
|
+
except ValueError:
|
|
324
|
+
name = path.name
|
|
325
|
+
components = []
|
|
326
|
+
for component in name.split("/"):
|
|
327
|
+
normalized = re.sub(r"[^A-Za-z0-9._-]+", "-", component).strip("-")
|
|
328
|
+
if normalized:
|
|
329
|
+
components.append(normalized)
|
|
330
|
+
safe_name = "/".join(components) or "model"
|
|
331
|
+
digest = structure_digest.removeprefix("sha256:")[:12]
|
|
332
|
+
return f"model://{namespace}/{safe_name}@{digest}"
|
|
333
|
+
|
|
334
|
+
def discover_models(
|
|
335
|
+
self,
|
|
336
|
+
*,
|
|
337
|
+
root: str,
|
|
338
|
+
max_depth: int = 5,
|
|
339
|
+
register: bool = False,
|
|
340
|
+
namespace: str = "discovered",
|
|
341
|
+
) -> dict[str, Any]:
|
|
342
|
+
root_path = Path(root).expanduser().resolve(strict=True)
|
|
343
|
+
if not root_path.is_dir():
|
|
344
|
+
raise ValueError(f"discovery root must be a directory: {root_path}")
|
|
345
|
+
if not 0 <= max_depth <= 20:
|
|
346
|
+
raise ValueError("max_depth must be between 0 and 20")
|
|
347
|
+
if not re.fullmatch(r"[A-Za-z0-9._-]+", namespace):
|
|
348
|
+
raise ValueError("namespace may only contain letters, numbers, dot, underscore, and dash")
|
|
349
|
+
|
|
350
|
+
found: list[dict[str, Any]] = []
|
|
351
|
+
for current, dirs, _files in os.walk(root_path, followlinks=False):
|
|
352
|
+
current_path = Path(current)
|
|
353
|
+
depth = len(current_path.relative_to(root_path).parts)
|
|
354
|
+
dirs[:] = [
|
|
355
|
+
name
|
|
356
|
+
for name in dirs
|
|
357
|
+
if name not in _DISCOVERY_SKIP_DIRS
|
|
358
|
+
and not (current_path / name).is_symlink()
|
|
359
|
+
]
|
|
360
|
+
if depth > max_depth:
|
|
361
|
+
dirs[:] = []
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
detected = self._model_metadata(current_path)
|
|
365
|
+
if detected is None:
|
|
366
|
+
if depth == max_depth:
|
|
367
|
+
dirs[:] = []
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
format_name, config = detected
|
|
371
|
+
structure = self._portable_structure(current_path)
|
|
372
|
+
ref = self._discovered_ref(
|
|
373
|
+
root_path,
|
|
374
|
+
current_path,
|
|
375
|
+
config,
|
|
376
|
+
structure["structure_digest"],
|
|
377
|
+
namespace,
|
|
378
|
+
)
|
|
379
|
+
item = {
|
|
380
|
+
"ref": ref,
|
|
381
|
+
"path": str(current_path),
|
|
382
|
+
"format": format_name,
|
|
383
|
+
"size_bytes": structure["size_bytes"],
|
|
384
|
+
"file_count": structure["file_count"],
|
|
385
|
+
"structure_digest": structure["structure_digest"],
|
|
386
|
+
"model_type": config.get("model_type"),
|
|
387
|
+
"architectures": config.get("architectures"),
|
|
388
|
+
"registered": False,
|
|
389
|
+
}
|
|
390
|
+
if register:
|
|
391
|
+
registered = self.register_model(
|
|
392
|
+
ref=ref,
|
|
393
|
+
path=str(current_path),
|
|
394
|
+
format_name=format_name,
|
|
395
|
+
extra_metadata={
|
|
396
|
+
"model_type": config.get("model_type"),
|
|
397
|
+
"architectures": config.get("architectures"),
|
|
398
|
+
},
|
|
399
|
+
)
|
|
400
|
+
item["registered"] = True
|
|
401
|
+
item["state"] = registered["replicas"][0]["state"]
|
|
402
|
+
found.append(item)
|
|
403
|
+
dirs[:] = []
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
"root": str(root_path),
|
|
407
|
+
"max_depth": max_depth,
|
|
408
|
+
"registered": register,
|
|
409
|
+
"models": found,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
def _upsert_artifact(
|
|
413
|
+
self,
|
|
414
|
+
conn: sqlite3.Connection,
|
|
415
|
+
*,
|
|
416
|
+
ref: str,
|
|
417
|
+
manifest_digest: str | None,
|
|
418
|
+
format_name: str | None = None,
|
|
419
|
+
revision: str | None = None,
|
|
420
|
+
variant: str | None = None,
|
|
421
|
+
metadata: dict[str, Any] | None = None,
|
|
422
|
+
) -> int:
|
|
423
|
+
now = _utc_now()
|
|
424
|
+
conn.execute(
|
|
425
|
+
"""
|
|
426
|
+
INSERT INTO artifacts (
|
|
427
|
+
ref, revision, variant, format, manifest_digest,
|
|
428
|
+
metadata_json, created_at, updated_at
|
|
429
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
430
|
+
ON CONFLICT(ref) DO UPDATE SET
|
|
431
|
+
revision = COALESCE(excluded.revision, artifacts.revision),
|
|
432
|
+
variant = COALESCE(excluded.variant, artifacts.variant),
|
|
433
|
+
format = COALESCE(excluded.format, artifacts.format),
|
|
434
|
+
manifest_digest = COALESCE(excluded.manifest_digest, artifacts.manifest_digest),
|
|
435
|
+
metadata_json = excluded.metadata_json,
|
|
436
|
+
deleted_at = NULL,
|
|
437
|
+
updated_at = excluded.updated_at
|
|
438
|
+
""",
|
|
439
|
+
(
|
|
440
|
+
ref,
|
|
441
|
+
revision,
|
|
442
|
+
variant,
|
|
443
|
+
format_name,
|
|
444
|
+
manifest_digest,
|
|
445
|
+
json.dumps(metadata or {}, ensure_ascii=False, sort_keys=True),
|
|
446
|
+
now,
|
|
447
|
+
now,
|
|
448
|
+
),
|
|
449
|
+
)
|
|
450
|
+
row = conn.execute("SELECT id FROM artifacts WHERE ref = ?", (ref,)).fetchone()
|
|
451
|
+
assert row is not None
|
|
452
|
+
return int(row["id"])
|
|
453
|
+
|
|
454
|
+
def register_model(
|
|
455
|
+
self,
|
|
456
|
+
*,
|
|
457
|
+
ref: str,
|
|
458
|
+
path: str,
|
|
459
|
+
scope: str = "user",
|
|
460
|
+
format_name: str | None = None,
|
|
461
|
+
full_checksum: bool = False,
|
|
462
|
+
extra_metadata: dict[str, Any] | None = None,
|
|
463
|
+
) -> dict[str, Any]:
|
|
464
|
+
if not ref.strip():
|
|
465
|
+
raise ValueError("model ref must not be empty")
|
|
466
|
+
if scope not in {"user", "team", "system"}:
|
|
467
|
+
raise ValueError(f"unsupported scope: {scope}")
|
|
468
|
+
with self._connection() as conn:
|
|
469
|
+
existing = conn.execute(
|
|
470
|
+
"SELECT manifest_digest, metadata_json FROM artifacts WHERE ref = ?",
|
|
471
|
+
(ref,),
|
|
472
|
+
).fetchone()
|
|
473
|
+
existing_metadata = json.loads(existing["metadata_json"]) if existing else {}
|
|
474
|
+
require_content = bool(existing_metadata.get("content_digest"))
|
|
475
|
+
scanned = self.scan_path(Path(path), full_checksum=full_checksum or require_content)
|
|
476
|
+
with self._connection() as conn:
|
|
477
|
+
self._register_scanned(
|
|
478
|
+
conn,
|
|
479
|
+
ref=ref,
|
|
480
|
+
scanned=scanned,
|
|
481
|
+
scope=scope,
|
|
482
|
+
format_name=format_name,
|
|
483
|
+
extra_metadata=extra_metadata,
|
|
484
|
+
)
|
|
485
|
+
return self.inspect_model(ref)
|
|
486
|
+
|
|
487
|
+
def _register_scanned(
|
|
488
|
+
self,
|
|
489
|
+
conn: sqlite3.Connection,
|
|
490
|
+
*,
|
|
491
|
+
ref: str,
|
|
492
|
+
scanned: dict[str, Any],
|
|
493
|
+
scope: str,
|
|
494
|
+
format_name: str | None,
|
|
495
|
+
extra_metadata: dict[str, Any] | None = None,
|
|
496
|
+
) -> None:
|
|
497
|
+
existing = conn.execute(
|
|
498
|
+
"SELECT manifest_digest, metadata_json FROM artifacts WHERE ref = ?",
|
|
499
|
+
(ref,),
|
|
500
|
+
).fetchone()
|
|
501
|
+
existing_metadata = json.loads(existing["metadata_json"]) if existing else {}
|
|
502
|
+
require_content = bool(existing_metadata.get("content_digest"))
|
|
503
|
+
actual_identity = (
|
|
504
|
+
scanned["content_digest"]
|
|
505
|
+
if require_content
|
|
506
|
+
else scanned["metadata_digest"]
|
|
507
|
+
)
|
|
508
|
+
expected_identity = (
|
|
509
|
+
existing_metadata.get("content_digest")
|
|
510
|
+
or existing_metadata.get("metadata_digest")
|
|
511
|
+
or (existing["manifest_digest"] if existing else None)
|
|
512
|
+
)
|
|
513
|
+
state = "ready" if expected_identity in {None, actual_identity} else "dirty"
|
|
514
|
+
manifest = existing["manifest_digest"] if existing else (
|
|
515
|
+
scanned["content_digest"] or scanned["metadata_digest"]
|
|
516
|
+
)
|
|
517
|
+
verification = (
|
|
518
|
+
"checksum_verified"
|
|
519
|
+
if scanned["content_digest"] is not None and state == "ready"
|
|
520
|
+
else "metadata_verified"
|
|
521
|
+
)
|
|
522
|
+
now = _utc_now()
|
|
523
|
+
artifact_metadata = dict(existing_metadata)
|
|
524
|
+
if existing is None:
|
|
525
|
+
artifact_metadata.update(
|
|
526
|
+
{
|
|
527
|
+
"metadata_digest": scanned["metadata_digest"],
|
|
528
|
+
"content_digest": scanned["content_digest"],
|
|
529
|
+
}
|
|
530
|
+
)
|
|
531
|
+
artifact_metadata.update(extra_metadata or {})
|
|
532
|
+
artifact_id = self._upsert_artifact(
|
|
533
|
+
conn,
|
|
534
|
+
ref=ref,
|
|
535
|
+
manifest_digest=manifest,
|
|
536
|
+
format_name=format_name,
|
|
537
|
+
metadata=artifact_metadata,
|
|
538
|
+
)
|
|
539
|
+
conn.execute(
|
|
540
|
+
"""
|
|
541
|
+
INSERT INTO replicas (
|
|
542
|
+
artifact_id, node_id, path, scope, management, state,
|
|
543
|
+
verification_level, size_bytes, file_count, last_seen
|
|
544
|
+
) VALUES (?, ?, ?, ?, 'external', ?, ?, ?, ?, ?)
|
|
545
|
+
ON CONFLICT(node_id, path) DO UPDATE SET
|
|
546
|
+
artifact_id = excluded.artifact_id,
|
|
547
|
+
scope = excluded.scope,
|
|
548
|
+
state = excluded.state,
|
|
549
|
+
verification_level = excluded.verification_level,
|
|
550
|
+
size_bytes = excluded.size_bytes,
|
|
551
|
+
file_count = excluded.file_count,
|
|
552
|
+
last_seen = excluded.last_seen
|
|
553
|
+
""",
|
|
554
|
+
(
|
|
555
|
+
artifact_id,
|
|
556
|
+
self.node_id,
|
|
557
|
+
scanned["path"],
|
|
558
|
+
scope,
|
|
559
|
+
state,
|
|
560
|
+
verification,
|
|
561
|
+
scanned["size_bytes"],
|
|
562
|
+
scanned["file_count"],
|
|
563
|
+
now,
|
|
564
|
+
),
|
|
565
|
+
)
|
|
566
|
+
self._audit(
|
|
567
|
+
conn,
|
|
568
|
+
"model.register",
|
|
569
|
+
ref,
|
|
570
|
+
{
|
|
571
|
+
"path": scanned["path"],
|
|
572
|
+
"state": state,
|
|
573
|
+
"verification_level": verification,
|
|
574
|
+
},
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
def list_models(
|
|
578
|
+
self,
|
|
579
|
+
*,
|
|
580
|
+
format_name: str | None = None,
|
|
581
|
+
model_type: str | None = None,
|
|
582
|
+
state: str | None = None,
|
|
583
|
+
tag: str | None = None,
|
|
584
|
+
name: str | None = None,
|
|
585
|
+
include_deleted: bool = False,
|
|
586
|
+
) -> list[dict[str, Any]]:
|
|
587
|
+
with self._connection() as conn:
|
|
588
|
+
query = "SELECT * FROM artifacts"
|
|
589
|
+
if not include_deleted:
|
|
590
|
+
query += " WHERE deleted_at IS NULL"
|
|
591
|
+
query += " ORDER BY ref"
|
|
592
|
+
artifacts = conn.execute(query).fetchall()
|
|
593
|
+
results: list[dict[str, Any]] = []
|
|
594
|
+
for artifact in artifacts:
|
|
595
|
+
metadata = json.loads(artifact["metadata_json"])
|
|
596
|
+
aliases = [
|
|
597
|
+
row["alias"]
|
|
598
|
+
for row in conn.execute(
|
|
599
|
+
"SELECT alias FROM artifact_aliases WHERE artifact_id = ? ORDER BY alias",
|
|
600
|
+
(artifact["id"],),
|
|
601
|
+
).fetchall()
|
|
602
|
+
]
|
|
603
|
+
tags = [
|
|
604
|
+
row["tag"]
|
|
605
|
+
for row in conn.execute(
|
|
606
|
+
"SELECT tag FROM artifact_tags WHERE artifact_id = ? ORDER BY tag",
|
|
607
|
+
(artifact["id"],),
|
|
608
|
+
).fetchall()
|
|
609
|
+
]
|
|
610
|
+
if format_name and artifact["format"] != format_name:
|
|
611
|
+
continue
|
|
612
|
+
if model_type and metadata.get("model_type") != model_type:
|
|
613
|
+
continue
|
|
614
|
+
if tag and tag not in tags:
|
|
615
|
+
continue
|
|
616
|
+
replicas = conn.execute(
|
|
617
|
+
"SELECT * FROM replicas WHERE artifact_id = ? ORDER BY node_id, path",
|
|
618
|
+
(artifact["id"],),
|
|
619
|
+
).fetchall()
|
|
620
|
+
replica_values: list[sqlite3.Row | None] = list(replicas) or [None]
|
|
621
|
+
for replica in replica_values:
|
|
622
|
+
if state and (replica is None or replica["state"] != state):
|
|
623
|
+
continue
|
|
624
|
+
value = {
|
|
625
|
+
"ref": artifact["ref"],
|
|
626
|
+
"revision": artifact["revision"],
|
|
627
|
+
"variant": artifact["variant"],
|
|
628
|
+
"format": artifact["format"],
|
|
629
|
+
"manifest_digest": artifact["manifest_digest"],
|
|
630
|
+
"description": artifact["description"],
|
|
631
|
+
"deleted_at": artifact["deleted_at"],
|
|
632
|
+
"aliases": aliases,
|
|
633
|
+
"tags": tags,
|
|
634
|
+
"model_type": metadata.get("model_type"),
|
|
635
|
+
}
|
|
636
|
+
if replica is not None:
|
|
637
|
+
value.update(dict(replica))
|
|
638
|
+
value.pop("id", None)
|
|
639
|
+
value.pop("artifact_id", None)
|
|
640
|
+
else:
|
|
641
|
+
value.update(
|
|
642
|
+
{
|
|
643
|
+
"node_id": None,
|
|
644
|
+
"path": None,
|
|
645
|
+
"scope": None,
|
|
646
|
+
"management": None,
|
|
647
|
+
"state": None,
|
|
648
|
+
"verification_level": None,
|
|
649
|
+
"size_bytes": 0,
|
|
650
|
+
"file_count": 0,
|
|
651
|
+
"last_seen": None,
|
|
652
|
+
}
|
|
653
|
+
)
|
|
654
|
+
searchable = " ".join(
|
|
655
|
+
[
|
|
656
|
+
artifact["ref"],
|
|
657
|
+
*aliases,
|
|
658
|
+
value.get("path") or "",
|
|
659
|
+
artifact["description"] or "",
|
|
660
|
+
]
|
|
661
|
+
).lower()
|
|
662
|
+
if name and name.lower() not in searchable:
|
|
663
|
+
continue
|
|
664
|
+
results.append(value)
|
|
665
|
+
return results
|
|
666
|
+
|
|
667
|
+
def inspect_model(self, ref: str) -> dict[str, Any]:
|
|
668
|
+
with self._connection() as conn:
|
|
669
|
+
artifact = self._artifact_row(conn, ref)
|
|
670
|
+
replicas = conn.execute(
|
|
671
|
+
"SELECT * FROM replicas WHERE artifact_id = ? ORDER BY node_id, path",
|
|
672
|
+
(artifact["id"],),
|
|
673
|
+
).fetchall()
|
|
674
|
+
aliases = [
|
|
675
|
+
row["alias"]
|
|
676
|
+
for row in conn.execute(
|
|
677
|
+
"SELECT alias FROM artifact_aliases WHERE artifact_id = ? ORDER BY alias",
|
|
678
|
+
(artifact["id"],),
|
|
679
|
+
).fetchall()
|
|
680
|
+
]
|
|
681
|
+
tags = [
|
|
682
|
+
row["tag"]
|
|
683
|
+
for row in conn.execute(
|
|
684
|
+
"SELECT tag FROM artifact_tags WHERE artifact_id = ? ORDER BY tag",
|
|
685
|
+
(artifact["id"],),
|
|
686
|
+
).fetchall()
|
|
687
|
+
]
|
|
688
|
+
value = dict(artifact)
|
|
689
|
+
value["metadata"] = json.loads(value.pop("metadata_json"))
|
|
690
|
+
value["replicas"] = [dict(row) for row in replicas]
|
|
691
|
+
value["aliases"] = aliases
|
|
692
|
+
value["tags"] = tags
|
|
693
|
+
return value
|
|
694
|
+
|
|
695
|
+
def update_model(
|
|
696
|
+
self,
|
|
697
|
+
ref: str,
|
|
698
|
+
*,
|
|
699
|
+
add_aliases: Iterable[str] = (),
|
|
700
|
+
remove_aliases: Iterable[str] = (),
|
|
701
|
+
add_tags: Iterable[str] = (),
|
|
702
|
+
remove_tags: Iterable[str] = (),
|
|
703
|
+
description: str | None = None,
|
|
704
|
+
clear_description: bool = False,
|
|
705
|
+
) -> dict[str, Any]:
|
|
706
|
+
additions_alias = sorted({item.strip() for item in add_aliases if item.strip()})
|
|
707
|
+
removals_alias = sorted({item.strip() for item in remove_aliases if item.strip()})
|
|
708
|
+
additions_tag = sorted({item.strip() for item in add_tags if item.strip()})
|
|
709
|
+
removals_tag = sorted({item.strip() for item in remove_tags if item.strip()})
|
|
710
|
+
with self._connection() as conn:
|
|
711
|
+
artifact = self._artifact_row(conn, ref)
|
|
712
|
+
try:
|
|
713
|
+
for alias in additions_alias:
|
|
714
|
+
existing_alias = conn.execute(
|
|
715
|
+
"SELECT artifact_id FROM artifact_aliases WHERE alias = ?",
|
|
716
|
+
(alias,),
|
|
717
|
+
).fetchone()
|
|
718
|
+
if existing_alias is None:
|
|
719
|
+
conn.execute(
|
|
720
|
+
"INSERT INTO artifact_aliases (artifact_id, alias) VALUES (?, ?)",
|
|
721
|
+
(artifact["id"], alias),
|
|
722
|
+
)
|
|
723
|
+
elif existing_alias["artifact_id"] != artifact["id"]:
|
|
724
|
+
raise ValueError(
|
|
725
|
+
f"alias already belongs to another model: {alias}"
|
|
726
|
+
)
|
|
727
|
+
except sqlite3.IntegrityError as exc:
|
|
728
|
+
raise ValueError(f"alias already belongs to another model: {alias}") from exc
|
|
729
|
+
for alias in removals_alias:
|
|
730
|
+
conn.execute(
|
|
731
|
+
"DELETE FROM artifact_aliases WHERE artifact_id = ? AND alias = ?",
|
|
732
|
+
(artifact["id"], alias),
|
|
733
|
+
)
|
|
734
|
+
for tag in additions_tag:
|
|
735
|
+
conn.execute(
|
|
736
|
+
"INSERT OR IGNORE INTO artifact_tags (artifact_id, tag) VALUES (?, ?)",
|
|
737
|
+
(artifact["id"], tag),
|
|
738
|
+
)
|
|
739
|
+
for tag in removals_tag:
|
|
740
|
+
conn.execute(
|
|
741
|
+
"DELETE FROM artifact_tags WHERE artifact_id = ? AND tag = ?",
|
|
742
|
+
(artifact["id"], tag),
|
|
743
|
+
)
|
|
744
|
+
if description is not None or clear_description:
|
|
745
|
+
conn.execute(
|
|
746
|
+
"UPDATE artifacts SET description = ?, updated_at = ? WHERE id = ?",
|
|
747
|
+
(None if clear_description else description, _utc_now(), artifact["id"]),
|
|
748
|
+
)
|
|
749
|
+
self._audit(
|
|
750
|
+
conn,
|
|
751
|
+
"model.update",
|
|
752
|
+
artifact["ref"],
|
|
753
|
+
{
|
|
754
|
+
"add_aliases": additions_alias,
|
|
755
|
+
"remove_aliases": removals_alias,
|
|
756
|
+
"add_tags": additions_tag,
|
|
757
|
+
"remove_tags": removals_tag,
|
|
758
|
+
"description_changed": description is not None or clear_description,
|
|
759
|
+
},
|
|
760
|
+
)
|
|
761
|
+
return self.inspect_model(artifact["ref"])
|
|
762
|
+
|
|
763
|
+
def delete_model(self, ref: str) -> dict[str, Any]:
|
|
764
|
+
with self._connection() as conn:
|
|
765
|
+
artifact = self._artifact_row(conn, ref)
|
|
766
|
+
deleted_at = _utc_now()
|
|
767
|
+
conn.execute(
|
|
768
|
+
"UPDATE artifacts SET deleted_at = ?, updated_at = ? WHERE id = ?",
|
|
769
|
+
(deleted_at, deleted_at, artifact["id"]),
|
|
770
|
+
)
|
|
771
|
+
self._audit(conn, "model.delete", artifact["ref"])
|
|
772
|
+
return {"ref": artifact["ref"], "deleted_at": deleted_at}
|
|
773
|
+
|
|
774
|
+
def restore_model(self, ref: str) -> dict[str, Any]:
|
|
775
|
+
with self._connection() as conn:
|
|
776
|
+
artifact = self._artifact_row(conn, ref)
|
|
777
|
+
conn.execute(
|
|
778
|
+
"UPDATE artifacts SET deleted_at = NULL, updated_at = ? WHERE id = ?",
|
|
779
|
+
(_utc_now(), artifact["id"]),
|
|
780
|
+
)
|
|
781
|
+
self._audit(conn, "model.restore", artifact["ref"])
|
|
782
|
+
return self.inspect_model(artifact["ref"])
|
|
783
|
+
|
|
784
|
+
def list_replicas(self, ref: str) -> dict[str, Any]:
|
|
785
|
+
model = self.inspect_model(ref)
|
|
786
|
+
return {
|
|
787
|
+
"ref": model["ref"],
|
|
788
|
+
"deleted_at": model["deleted_at"],
|
|
789
|
+
"replicas": model["replicas"],
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
def remove_replica(self, ref: str, path: str) -> dict[str, Any]:
|
|
793
|
+
resolved = str(Path(path).expanduser().resolve())
|
|
794
|
+
with self._connection() as conn:
|
|
795
|
+
artifact = self._artifact_row(conn, ref)
|
|
796
|
+
removed = conn.execute(
|
|
797
|
+
"""
|
|
798
|
+
DELETE FROM replicas
|
|
799
|
+
WHERE artifact_id = ? AND node_id = ? AND path = ?
|
|
800
|
+
""",
|
|
801
|
+
(artifact["id"], self.node_id, resolved),
|
|
802
|
+
).rowcount
|
|
803
|
+
self._audit(
|
|
804
|
+
conn,
|
|
805
|
+
"replica.remove",
|
|
806
|
+
artifact["ref"],
|
|
807
|
+
{"path": resolved, "removed": int(removed)},
|
|
808
|
+
)
|
|
809
|
+
return {"ref": artifact["ref"], "path": resolved, "removed": int(removed)}
|
|
810
|
+
|
|
811
|
+
def refresh_model(
|
|
812
|
+
self,
|
|
813
|
+
ref: str,
|
|
814
|
+
*,
|
|
815
|
+
path: str | None = None,
|
|
816
|
+
full_checksum: bool = False,
|
|
817
|
+
) -> dict[str, Any]:
|
|
818
|
+
model = self.inspect_model(ref)
|
|
819
|
+
replicas = [
|
|
820
|
+
item
|
|
821
|
+
for item in model["replicas"]
|
|
822
|
+
if item["node_id"] == self.node_id
|
|
823
|
+
and (path is None or item["path"] == str(Path(path).expanduser().resolve()))
|
|
824
|
+
]
|
|
825
|
+
if not replicas:
|
|
826
|
+
raise KeyError(f"model has no matching replica on this node: {ref}")
|
|
827
|
+
scans: list[tuple[dict[str, Any], dict[str, Any] | None]] = []
|
|
828
|
+
for replica in replicas:
|
|
829
|
+
replica_path = Path(replica["path"])
|
|
830
|
+
scan = (
|
|
831
|
+
self.scan_path(replica_path, full_checksum=full_checksum)
|
|
832
|
+
if replica_path.exists()
|
|
833
|
+
else None
|
|
834
|
+
)
|
|
835
|
+
scans.append((replica, scan))
|
|
836
|
+
baseline = next((scan for _replica, scan in scans if scan is not None), None)
|
|
837
|
+
if baseline is None:
|
|
838
|
+
raise FileNotFoundError("no matching replica paths exist")
|
|
839
|
+
identity_key = "content_digest" if full_checksum else "metadata_digest"
|
|
840
|
+
metadata = dict(model["metadata"])
|
|
841
|
+
metadata["metadata_digest"] = baseline["metadata_digest"]
|
|
842
|
+
metadata["content_digest"] = baseline["content_digest"] if full_checksum else None
|
|
843
|
+
now = _utc_now()
|
|
844
|
+
with self._connection() as conn:
|
|
845
|
+
conn.execute(
|
|
846
|
+
"""
|
|
847
|
+
UPDATE artifacts
|
|
848
|
+
SET manifest_digest = ?, metadata_json = ?, deleted_at = NULL, updated_at = ?
|
|
849
|
+
WHERE id = ?
|
|
850
|
+
""",
|
|
851
|
+
(
|
|
852
|
+
baseline[identity_key],
|
|
853
|
+
json.dumps(metadata, ensure_ascii=False, sort_keys=True),
|
|
854
|
+
now,
|
|
855
|
+
model["id"],
|
|
856
|
+
),
|
|
857
|
+
)
|
|
858
|
+
results = []
|
|
859
|
+
for replica, scan in scans:
|
|
860
|
+
if scan is None:
|
|
861
|
+
state = "missing"
|
|
862
|
+
verification = "metadata_verified"
|
|
863
|
+
else:
|
|
864
|
+
state = (
|
|
865
|
+
"ready"
|
|
866
|
+
if scan[identity_key] == baseline[identity_key]
|
|
867
|
+
else "dirty"
|
|
868
|
+
)
|
|
869
|
+
verification = (
|
|
870
|
+
"checksum_verified" if full_checksum and state == "ready"
|
|
871
|
+
else "metadata_verified"
|
|
872
|
+
)
|
|
873
|
+
conn.execute(
|
|
874
|
+
"""
|
|
875
|
+
UPDATE replicas
|
|
876
|
+
SET state = ?, verification_level = ?,
|
|
877
|
+
size_bytes = ?, file_count = ?, last_seen = ?
|
|
878
|
+
WHERE id = ?
|
|
879
|
+
""",
|
|
880
|
+
(
|
|
881
|
+
state,
|
|
882
|
+
verification,
|
|
883
|
+
scan["size_bytes"] if scan else 0,
|
|
884
|
+
scan["file_count"] if scan else 0,
|
|
885
|
+
now,
|
|
886
|
+
replica["id"],
|
|
887
|
+
),
|
|
888
|
+
)
|
|
889
|
+
results.append({"path": replica["path"], "state": state, "scan": scan})
|
|
890
|
+
self._audit(
|
|
891
|
+
conn,
|
|
892
|
+
"model.refresh",
|
|
893
|
+
model["ref"],
|
|
894
|
+
{"paths": [item["path"] for item, _scan in scans], "full_checksum": full_checksum},
|
|
895
|
+
)
|
|
896
|
+
return {"ref": model["ref"], "results": results}
|
|
897
|
+
|
|
898
|
+
def validate_model(self, ref: str, path: str | None = None) -> dict[str, Any]:
|
|
899
|
+
model = self.inspect_model(ref)
|
|
900
|
+
replicas = [
|
|
901
|
+
item
|
|
902
|
+
for item in model["replicas"]
|
|
903
|
+
if item["node_id"] == self.node_id
|
|
904
|
+
and (path is None or item["path"] == str(Path(path).expanduser().resolve()))
|
|
905
|
+
]
|
|
906
|
+
if not replicas:
|
|
907
|
+
raise KeyError(f"model has no matching replica on this node: {ref}")
|
|
908
|
+
results = []
|
|
909
|
+
for replica in replicas:
|
|
910
|
+
root = Path(replica["path"])
|
|
911
|
+
missing: list[str] = []
|
|
912
|
+
index_files = [
|
|
913
|
+
root / "model.safetensors.index.json",
|
|
914
|
+
root / "pytorch_model.bin.index.json",
|
|
915
|
+
]
|
|
916
|
+
index_path = next((item for item in index_files if item.is_file()), None)
|
|
917
|
+
referenced: list[str] = []
|
|
918
|
+
parse_error = None
|
|
919
|
+
if index_path is not None:
|
|
920
|
+
try:
|
|
921
|
+
index = json.loads(index_path.read_text(encoding="utf-8"))
|
|
922
|
+
weight_map = index.get("weight_map") or {}
|
|
923
|
+
referenced = sorted(
|
|
924
|
+
{str(item) for item in weight_map.values()}
|
|
925
|
+
)
|
|
926
|
+
missing = [
|
|
927
|
+
item for item in referenced if not (root / item).is_file()
|
|
928
|
+
]
|
|
929
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError, AttributeError) as exc:
|
|
930
|
+
parse_error = str(exc)
|
|
931
|
+
weights = sorted(
|
|
932
|
+
item.name
|
|
933
|
+
for item in root.iterdir()
|
|
934
|
+
if item.is_file()
|
|
935
|
+
and item.suffix.lower() in {".safetensors", ".bin", ".gguf"}
|
|
936
|
+
) if root.is_dir() else []
|
|
937
|
+
has_config = (root / "config.json").is_file() or (
|
|
938
|
+
root / "adapter_config.json"
|
|
939
|
+
).is_file()
|
|
940
|
+
valid = bool(root.is_dir() and weights and not missing and not parse_error)
|
|
941
|
+
if not any(item.endswith(".gguf") for item in weights):
|
|
942
|
+
valid = valid and has_config
|
|
943
|
+
results.append(
|
|
944
|
+
{
|
|
945
|
+
"path": replica["path"],
|
|
946
|
+
"valid": valid,
|
|
947
|
+
"index": str(index_path) if index_path else None,
|
|
948
|
+
"referenced_shards": len(referenced),
|
|
949
|
+
"present_weight_files": len(weights),
|
|
950
|
+
"missing_files": missing,
|
|
951
|
+
"parse_error": parse_error,
|
|
952
|
+
}
|
|
953
|
+
)
|
|
954
|
+
return {"ref": model["ref"], "valid": all(item["valid"] for item in results), "results": results}
|
|
955
|
+
|
|
956
|
+
def audit_records(self, ref: str | None = None, limit: int = 100) -> list[dict[str, Any]]:
|
|
957
|
+
if not 1 <= limit <= 1000:
|
|
958
|
+
raise ValueError("audit limit must be between 1 and 1000")
|
|
959
|
+
with self._connection() as conn:
|
|
960
|
+
if ref:
|
|
961
|
+
artifact = self._artifact_row(conn, ref)
|
|
962
|
+
rows = conn.execute(
|
|
963
|
+
"SELECT * FROM audit_log WHERE ref = ? ORDER BY id DESC LIMIT ?",
|
|
964
|
+
(artifact["ref"], limit),
|
|
965
|
+
).fetchall()
|
|
966
|
+
else:
|
|
967
|
+
rows = conn.execute(
|
|
968
|
+
"SELECT * FROM audit_log ORDER BY id DESC LIMIT ?",
|
|
969
|
+
(limit,),
|
|
970
|
+
).fetchall()
|
|
971
|
+
results = []
|
|
972
|
+
for row in rows:
|
|
973
|
+
value = dict(row)
|
|
974
|
+
value["details"] = json.loads(value.pop("details_json"))
|
|
975
|
+
results.append(value)
|
|
976
|
+
return results
|
|
977
|
+
|
|
978
|
+
def unregister_model(self, ref: str, path: str | None = None) -> dict[str, Any]:
|
|
979
|
+
with self._connection() as conn:
|
|
980
|
+
artifact = self._artifact_row(conn, ref)
|
|
981
|
+
if path is None:
|
|
982
|
+
removed = conn.execute(
|
|
983
|
+
"DELETE FROM replicas WHERE artifact_id = ? AND node_id = ?",
|
|
984
|
+
(artifact["id"], self.node_id),
|
|
985
|
+
).rowcount
|
|
986
|
+
else:
|
|
987
|
+
resolved = str(Path(path).expanduser().resolve())
|
|
988
|
+
removed = conn.execute(
|
|
989
|
+
"DELETE FROM replicas WHERE artifact_id = ? AND node_id = ? AND path = ?",
|
|
990
|
+
(artifact["id"], self.node_id, resolved),
|
|
991
|
+
).rowcount
|
|
992
|
+
remaining = conn.execute(
|
|
993
|
+
"SELECT COUNT(*) AS count FROM replicas WHERE artifact_id = ?",
|
|
994
|
+
(artifact["id"],),
|
|
995
|
+
).fetchone()["count"]
|
|
996
|
+
if remaining == 0:
|
|
997
|
+
conn.execute(
|
|
998
|
+
"UPDATE artifacts SET deleted_at = ?, updated_at = ? WHERE id = ?",
|
|
999
|
+
(_utc_now(), _utc_now(), artifact["id"]),
|
|
1000
|
+
)
|
|
1001
|
+
self._audit(
|
|
1002
|
+
conn,
|
|
1003
|
+
"model.unregister",
|
|
1004
|
+
artifact["ref"],
|
|
1005
|
+
{
|
|
1006
|
+
"path": path,
|
|
1007
|
+
"removed_replicas": int(removed),
|
|
1008
|
+
"soft_deleted": remaining == 0,
|
|
1009
|
+
},
|
|
1010
|
+
)
|
|
1011
|
+
return {
|
|
1012
|
+
"removed_replicas": int(removed),
|
|
1013
|
+
"soft_deleted": remaining == 0,
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
def verify_model(self, ref: str, full_checksum: bool = False) -> dict[str, Any]:
|
|
1017
|
+
model = self.inspect_model(ref)
|
|
1018
|
+
local = [item for item in model["replicas"] if item["node_id"] == self.node_id]
|
|
1019
|
+
if not local:
|
|
1020
|
+
raise KeyError(f"model has no replica on this node: {ref}")
|
|
1021
|
+
results = []
|
|
1022
|
+
with self._connection() as conn:
|
|
1023
|
+
for replica in local:
|
|
1024
|
+
path = Path(replica["path"])
|
|
1025
|
+
if not path.exists():
|
|
1026
|
+
state = "missing"
|
|
1027
|
+
scanned = None
|
|
1028
|
+
else:
|
|
1029
|
+
require_content = full_checksum or bool(model["metadata"].get("content_digest"))
|
|
1030
|
+
scanned = self.scan_path(path, full_checksum=require_content)
|
|
1031
|
+
actual = (
|
|
1032
|
+
scanned["content_digest"]
|
|
1033
|
+
if require_content
|
|
1034
|
+
else scanned["metadata_digest"]
|
|
1035
|
+
)
|
|
1036
|
+
comparable_expected = (
|
|
1037
|
+
model["metadata"].get("content_digest")
|
|
1038
|
+
if require_content
|
|
1039
|
+
else model["metadata"].get("metadata_digest")
|
|
1040
|
+
)
|
|
1041
|
+
state = "ready" if comparable_expected in {None, actual} else "dirty"
|
|
1042
|
+
verification = (
|
|
1043
|
+
"checksum_verified"
|
|
1044
|
+
if scanned and scanned["content_digest"] and state == "ready"
|
|
1045
|
+
else "metadata_verified"
|
|
1046
|
+
)
|
|
1047
|
+
if (
|
|
1048
|
+
scanned
|
|
1049
|
+
and full_checksum
|
|
1050
|
+
and state == "ready"
|
|
1051
|
+
and not model["metadata"].get("content_digest")
|
|
1052
|
+
):
|
|
1053
|
+
metadata = dict(model["metadata"])
|
|
1054
|
+
metadata["content_digest"] = scanned["content_digest"]
|
|
1055
|
+
conn.execute(
|
|
1056
|
+
"""
|
|
1057
|
+
UPDATE artifacts
|
|
1058
|
+
SET manifest_digest = ?, metadata_json = ?, updated_at = ?
|
|
1059
|
+
WHERE id = ?
|
|
1060
|
+
""",
|
|
1061
|
+
(
|
|
1062
|
+
scanned["content_digest"],
|
|
1063
|
+
json.dumps(metadata, ensure_ascii=False, sort_keys=True),
|
|
1064
|
+
_utc_now(),
|
|
1065
|
+
model["id"],
|
|
1066
|
+
),
|
|
1067
|
+
)
|
|
1068
|
+
conn.execute(
|
|
1069
|
+
"""
|
|
1070
|
+
UPDATE replicas
|
|
1071
|
+
SET state = ?, verification_level = ?, last_seen = ?
|
|
1072
|
+
WHERE id = ?
|
|
1073
|
+
""",
|
|
1074
|
+
(state, verification, _utc_now(), replica["id"]),
|
|
1075
|
+
)
|
|
1076
|
+
results.append({"path": replica["path"], "state": state, "scan": scanned})
|
|
1077
|
+
return {"ref": ref, "results": results}
|
|
1078
|
+
|
|
1079
|
+
def export_records(self) -> dict[str, Any]:
|
|
1080
|
+
exported_at = _utc_now()
|
|
1081
|
+
header = {
|
|
1082
|
+
"type": "meta",
|
|
1083
|
+
"schema_version": SCHEMA_VERSION,
|
|
1084
|
+
"exported_at": exported_at,
|
|
1085
|
+
"source_node": self.node_id,
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
def json_size(value: Any) -> int:
|
|
1089
|
+
try:
|
|
1090
|
+
text = json.dumps(
|
|
1091
|
+
value,
|
|
1092
|
+
ensure_ascii=False,
|
|
1093
|
+
sort_keys=True,
|
|
1094
|
+
allow_nan=False,
|
|
1095
|
+
)
|
|
1096
|
+
return len(text.encode("utf-8"))
|
|
1097
|
+
except (TypeError, ValueError, UnicodeEncodeError) as exc:
|
|
1098
|
+
raise ValueError(
|
|
1099
|
+
f"inventory export contains invalid JSON data: {exc}"
|
|
1100
|
+
) from exc
|
|
1101
|
+
|
|
1102
|
+
serialized_bytes = json_size(header) + 1
|
|
1103
|
+
if serialized_bytes > MAX_INVENTORY_CONTENT_BYTES:
|
|
1104
|
+
raise ValueError("inventory export content exceeds byte limit")
|
|
1105
|
+
artifacts: list[dict[str, Any]] = []
|
|
1106
|
+
total_aliases = 0
|
|
1107
|
+
total_tags = 0
|
|
1108
|
+
total_replicas = 0
|
|
1109
|
+
with self._connection() as conn:
|
|
1110
|
+
rows = conn.execute("SELECT * FROM artifacts ORDER BY ref")
|
|
1111
|
+
for artifact_index, row in enumerate(rows):
|
|
1112
|
+
if artifact_index >= MAX_INVENTORY_ARTIFACTS:
|
|
1113
|
+
raise ValueError("inventory contains too many artifacts")
|
|
1114
|
+
artifact = dict(row)
|
|
1115
|
+
artifact["metadata"] = json.loads(artifact.pop("metadata_json"))
|
|
1116
|
+
artifact["aliases"] = []
|
|
1117
|
+
artifact["tags"] = []
|
|
1118
|
+
artifact["replicas"] = []
|
|
1119
|
+
artifact_id = artifact["id"]
|
|
1120
|
+
artifact.pop("id", None)
|
|
1121
|
+
|
|
1122
|
+
artifact_size = json_size(
|
|
1123
|
+
{"type": "artifact", "value": artifact}
|
|
1124
|
+
)
|
|
1125
|
+
if (
|
|
1126
|
+
serialized_bytes + artifact_size + 1
|
|
1127
|
+
> MAX_INVENTORY_CONTENT_BYTES
|
|
1128
|
+
):
|
|
1129
|
+
raise ValueError("inventory export content exceeds byte limit")
|
|
1130
|
+
|
|
1131
|
+
def append_item(
|
|
1132
|
+
field: str,
|
|
1133
|
+
item: Any,
|
|
1134
|
+
*,
|
|
1135
|
+
per_artifact_limit: int,
|
|
1136
|
+
) -> None:
|
|
1137
|
+
nonlocal artifact_size
|
|
1138
|
+
values = artifact[field]
|
|
1139
|
+
if len(values) >= per_artifact_limit:
|
|
1140
|
+
raise ValueError(
|
|
1141
|
+
f"inventory artifact has too many {field}"
|
|
1142
|
+
)
|
|
1143
|
+
added_size = json_size(item) + (2 if values else 0)
|
|
1144
|
+
if (
|
|
1145
|
+
serialized_bytes + artifact_size + added_size + 1
|
|
1146
|
+
> MAX_INVENTORY_CONTENT_BYTES
|
|
1147
|
+
):
|
|
1148
|
+
raise ValueError(
|
|
1149
|
+
"inventory export content exceeds byte limit"
|
|
1150
|
+
)
|
|
1151
|
+
values.append(item)
|
|
1152
|
+
artifact_size += added_size
|
|
1153
|
+
|
|
1154
|
+
for value in conn.execute(
|
|
1155
|
+
"""
|
|
1156
|
+
SELECT alias FROM artifact_aliases
|
|
1157
|
+
WHERE artifact_id = ? ORDER BY alias
|
|
1158
|
+
""",
|
|
1159
|
+
(artifact_id,),
|
|
1160
|
+
):
|
|
1161
|
+
total_aliases += 1
|
|
1162
|
+
if total_aliases > MAX_INVENTORY_TOTAL_ALIASES:
|
|
1163
|
+
raise ValueError("inventory has too many total aliases")
|
|
1164
|
+
append_item(
|
|
1165
|
+
"aliases",
|
|
1166
|
+
value["alias"],
|
|
1167
|
+
per_artifact_limit=MAX_INVENTORY_ALIASES_PER_ARTIFACT,
|
|
1168
|
+
)
|
|
1169
|
+
for value in conn.execute(
|
|
1170
|
+
"""
|
|
1171
|
+
SELECT tag FROM artifact_tags
|
|
1172
|
+
WHERE artifact_id = ? ORDER BY tag
|
|
1173
|
+
""",
|
|
1174
|
+
(artifact_id,),
|
|
1175
|
+
):
|
|
1176
|
+
total_tags += 1
|
|
1177
|
+
if total_tags > MAX_INVENTORY_TOTAL_TAGS:
|
|
1178
|
+
raise ValueError("inventory has too many total tags")
|
|
1179
|
+
append_item(
|
|
1180
|
+
"tags",
|
|
1181
|
+
value["tag"],
|
|
1182
|
+
per_artifact_limit=MAX_INVENTORY_TAGS_PER_ARTIFACT,
|
|
1183
|
+
)
|
|
1184
|
+
for value in conn.execute(
|
|
1185
|
+
"""
|
|
1186
|
+
SELECT * FROM replicas
|
|
1187
|
+
WHERE artifact_id = ? ORDER BY node_id, path
|
|
1188
|
+
""",
|
|
1189
|
+
(artifact_id,),
|
|
1190
|
+
):
|
|
1191
|
+
total_replicas += 1
|
|
1192
|
+
if total_replicas > MAX_INVENTORY_TOTAL_REPLICAS:
|
|
1193
|
+
raise ValueError("inventory has too many total replicas")
|
|
1194
|
+
replica = dict(value)
|
|
1195
|
+
replica.pop("id", None)
|
|
1196
|
+
replica.pop("artifact_id", None)
|
|
1197
|
+
append_item(
|
|
1198
|
+
"replicas",
|
|
1199
|
+
replica,
|
|
1200
|
+
per_artifact_limit=MAX_INVENTORY_REPLICAS_PER_ARTIFACT,
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
exact_size = json_size(
|
|
1204
|
+
{"type": "artifact", "value": artifact}
|
|
1205
|
+
)
|
|
1206
|
+
if (
|
|
1207
|
+
serialized_bytes + exact_size + 1
|
|
1208
|
+
> MAX_INVENTORY_CONTENT_BYTES
|
|
1209
|
+
):
|
|
1210
|
+
raise ValueError("inventory export content exceeds byte limit")
|
|
1211
|
+
artifacts.append(artifact)
|
|
1212
|
+
serialized_bytes += exact_size + 1
|
|
1213
|
+
return {
|
|
1214
|
+
"schema_version": SCHEMA_VERSION,
|
|
1215
|
+
"exported_at": exported_at,
|
|
1216
|
+
"source_node": self.node_id,
|
|
1217
|
+
"artifacts": artifacts,
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
@staticmethod
|
|
1221
|
+
def _normalize_root_maps(
|
|
1222
|
+
root_maps: Iterable[tuple[str, str]],
|
|
1223
|
+
) -> tuple[tuple[Path, Path], ...]:
|
|
1224
|
+
result: list[tuple[Path, Path]] = []
|
|
1225
|
+
for index, item in enumerate(root_maps):
|
|
1226
|
+
if index >= MAX_ROOT_MAPS:
|
|
1227
|
+
raise ValueError(
|
|
1228
|
+
f"root maps must contain at most {MAX_ROOT_MAPS} pairs"
|
|
1229
|
+
)
|
|
1230
|
+
if (
|
|
1231
|
+
not isinstance(item, (list, tuple))
|
|
1232
|
+
or isinstance(item, (str, bytes))
|
|
1233
|
+
or len(item) != 2
|
|
1234
|
+
):
|
|
1235
|
+
raise ValueError(
|
|
1236
|
+
"root maps must contain old/new path pairs"
|
|
1237
|
+
)
|
|
1238
|
+
old, new = item
|
|
1239
|
+
old_text = str(old)
|
|
1240
|
+
new_text = str(new)
|
|
1241
|
+
for value, name in ((old_text, "old"), (new_text, "new")):
|
|
1242
|
+
if not value:
|
|
1243
|
+
raise ValueError(f"root mapping {name} must not be empty")
|
|
1244
|
+
try:
|
|
1245
|
+
path_bytes = len(value.encode("utf-8"))
|
|
1246
|
+
except UnicodeEncodeError as exc:
|
|
1247
|
+
raise ValueError(
|
|
1248
|
+
f"root mapping {name} is not valid UTF-8"
|
|
1249
|
+
) from exc
|
|
1250
|
+
if path_bytes > MAX_ROOT_MAP_PATH_BYTES:
|
|
1251
|
+
raise ValueError(
|
|
1252
|
+
f"root mapping {name} exceeds "
|
|
1253
|
+
f"{MAX_ROOT_MAP_PATH_BYTES} UTF-8 bytes"
|
|
1254
|
+
)
|
|
1255
|
+
result.append(
|
|
1256
|
+
(Path(old_text).expanduser(), Path(new_text).expanduser())
|
|
1257
|
+
)
|
|
1258
|
+
return tuple(result)
|
|
1259
|
+
|
|
1260
|
+
@staticmethod
|
|
1261
|
+
def _mapped_path(
|
|
1262
|
+
path: str,
|
|
1263
|
+
root_maps: Iterable[tuple[Path, Path]],
|
|
1264
|
+
) -> str | None:
|
|
1265
|
+
source = Path(path)
|
|
1266
|
+
for old, new in root_maps:
|
|
1267
|
+
try:
|
|
1268
|
+
relative = source.relative_to(old)
|
|
1269
|
+
except ValueError:
|
|
1270
|
+
continue
|
|
1271
|
+
return str(new / relative)
|
|
1272
|
+
return None
|
|
1273
|
+
|
|
1274
|
+
def import_records(
|
|
1275
|
+
self,
|
|
1276
|
+
payload: dict[str, Any],
|
|
1277
|
+
root_maps: Iterable[tuple[str, str]] = (),
|
|
1278
|
+
) -> dict[str, int]:
|
|
1279
|
+
if payload.get("schema_version") != SCHEMA_VERSION:
|
|
1280
|
+
raise ValueError(f"unsupported inventory schema: {payload.get('schema_version')}")
|
|
1281
|
+
maps = self._normalize_root_maps(root_maps)
|
|
1282
|
+
artifacts = payload.get("artifacts", [])
|
|
1283
|
+
if not isinstance(artifacts, list):
|
|
1284
|
+
raise ValueError("inventory artifacts must be an array")
|
|
1285
|
+
if len(artifacts) > MAX_INVENTORY_ARTIFACTS:
|
|
1286
|
+
raise ValueError("inventory contains too many artifacts")
|
|
1287
|
+
total_aliases = 0
|
|
1288
|
+
total_tags = 0
|
|
1289
|
+
total_replicas = 0
|
|
1290
|
+
for artifact in artifacts:
|
|
1291
|
+
if not isinstance(artifact, dict):
|
|
1292
|
+
raise ValueError("each inventory artifact must be an object")
|
|
1293
|
+
aliases = artifact.get("aliases", [])
|
|
1294
|
+
tags = artifact.get("tags", [])
|
|
1295
|
+
replicas = artifact.get("replicas", [])
|
|
1296
|
+
for name, values in (
|
|
1297
|
+
("aliases", aliases),
|
|
1298
|
+
("tags", tags),
|
|
1299
|
+
("replicas", replicas),
|
|
1300
|
+
):
|
|
1301
|
+
if not isinstance(values, list):
|
|
1302
|
+
raise ValueError(
|
|
1303
|
+
f"inventory artifact {name} must be an array"
|
|
1304
|
+
)
|
|
1305
|
+
total_aliases += len(aliases)
|
|
1306
|
+
total_tags += len(tags)
|
|
1307
|
+
total_replicas += len(replicas)
|
|
1308
|
+
if total_aliases > MAX_INVENTORY_TOTAL_ALIASES:
|
|
1309
|
+
raise ValueError("inventory has too many total aliases")
|
|
1310
|
+
if total_tags > MAX_INVENTORY_TOTAL_TAGS:
|
|
1311
|
+
raise ValueError("inventory has too many total tags")
|
|
1312
|
+
if total_replicas > MAX_INVENTORY_TOTAL_REPLICAS:
|
|
1313
|
+
raise ValueError("inventory has too many total replicas")
|
|
1314
|
+
|
|
1315
|
+
prepared: list[
|
|
1316
|
+
tuple[dict[str, Any], str, tuple[dict[str, Any], str] | None]
|
|
1317
|
+
] = []
|
|
1318
|
+
for artifact in artifacts:
|
|
1319
|
+
ref = artifact.get("ref")
|
|
1320
|
+
if not ref:
|
|
1321
|
+
continue
|
|
1322
|
+
registration = None
|
|
1323
|
+
metadata = artifact.get("metadata") or {}
|
|
1324
|
+
require_content = bool(metadata.get("content_digest"))
|
|
1325
|
+
for replica in artifact.get("replicas", []):
|
|
1326
|
+
mapped = self._mapped_path(str(replica.get("path", "")), maps)
|
|
1327
|
+
if mapped and Path(mapped).expanduser().exists():
|
|
1328
|
+
scope = replica.get("scope", "user")
|
|
1329
|
+
if scope not in {"user", "team", "system"}:
|
|
1330
|
+
raise ValueError(f"unsupported scope: {scope}")
|
|
1331
|
+
registration = (
|
|
1332
|
+
self.scan_path(
|
|
1333
|
+
Path(mapped),
|
|
1334
|
+
full_checksum=require_content,
|
|
1335
|
+
),
|
|
1336
|
+
scope,
|
|
1337
|
+
)
|
|
1338
|
+
break
|
|
1339
|
+
prepared.append((artifact, ref, registration))
|
|
1340
|
+
|
|
1341
|
+
rebound_replicas = 0
|
|
1342
|
+
with self._connection() as conn:
|
|
1343
|
+
for artifact, ref, registration in prepared:
|
|
1344
|
+
artifact_id = self._upsert_artifact(
|
|
1345
|
+
conn,
|
|
1346
|
+
ref=ref,
|
|
1347
|
+
manifest_digest=artifact.get("manifest_digest"),
|
|
1348
|
+
format_name=artifact.get("format"),
|
|
1349
|
+
revision=artifact.get("revision"),
|
|
1350
|
+
variant=artifact.get("variant"),
|
|
1351
|
+
metadata=artifact.get("metadata") or {},
|
|
1352
|
+
)
|
|
1353
|
+
conn.execute(
|
|
1354
|
+
"""
|
|
1355
|
+
UPDATE artifacts
|
|
1356
|
+
SET description = ?, deleted_at = ?, updated_at = ?
|
|
1357
|
+
WHERE id = ?
|
|
1358
|
+
""",
|
|
1359
|
+
(
|
|
1360
|
+
artifact.get("description"),
|
|
1361
|
+
artifact.get("deleted_at"),
|
|
1362
|
+
_utc_now(),
|
|
1363
|
+
artifact_id,
|
|
1364
|
+
),
|
|
1365
|
+
)
|
|
1366
|
+
for alias in artifact.get("aliases") or []:
|
|
1367
|
+
conn.execute(
|
|
1368
|
+
"INSERT OR IGNORE INTO artifact_aliases (artifact_id, alias) VALUES (?, ?)",
|
|
1369
|
+
(artifact_id, str(alias)),
|
|
1370
|
+
)
|
|
1371
|
+
for tag in artifact.get("tags") or []:
|
|
1372
|
+
conn.execute(
|
|
1373
|
+
"INSERT OR IGNORE INTO artifact_tags (artifact_id, tag) VALUES (?, ?)",
|
|
1374
|
+
(artifact_id, str(tag)),
|
|
1375
|
+
)
|
|
1376
|
+
self._audit(conn, "model.import", ref)
|
|
1377
|
+
if registration is not None:
|
|
1378
|
+
scanned, scope = registration
|
|
1379
|
+
self._register_scanned(
|
|
1380
|
+
conn,
|
|
1381
|
+
ref=ref,
|
|
1382
|
+
scanned=scanned,
|
|
1383
|
+
scope=scope,
|
|
1384
|
+
format_name=artifact.get("format"),
|
|
1385
|
+
)
|
|
1386
|
+
rebound_replicas += 1
|
|
1387
|
+
return {
|
|
1388
|
+
"imported_artifacts": len(prepared),
|
|
1389
|
+
"rebound_replicas": rebound_replicas,
|
|
1390
|
+
}
|