hugpy-storage 0.2.0a0__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.
Files changed (44) hide show
  1. hugpy_storage/__init__.py +56 -0
  2. hugpy_storage/admission.py +435 -0
  3. hugpy_storage/archive_mark.py +136 -0
  4. hugpy_storage/catalog_source.py +269 -0
  5. hugpy_storage/cli.py +98 -0
  6. hugpy_storage/console/__init__.py +0 -0
  7. hugpy_storage/console/cancelable_downloads.py +87 -0
  8. hugpy_storage/console/downloader.py +113 -0
  9. hugpy_storage/console/downloads.py +57 -0
  10. hugpy_storage/console/model_physical.py +557 -0
  11. hugpy_storage/deploy/hugpy-downloader-dev.service +58 -0
  12. hugpy_storage/download_models.py +751 -0
  13. hugpy_storage/downloader/__init__.py +34 -0
  14. hugpy_storage/downloader/__main__.py +8 -0
  15. hugpy_storage/downloader/daemon.py +333 -0
  16. hugpy_storage/downloader/engine.py +577 -0
  17. hugpy_storage/downloader/presence.py +73 -0
  18. hugpy_storage/downloader/queue.py +253 -0
  19. hugpy_storage/env.py +16 -0
  20. hugpy_storage/events.py +53 -0
  21. hugpy_storage/format_select.py +252 -0
  22. hugpy_storage/gguf_inspect.py +705 -0
  23. hugpy_storage/hf_metadata.py +29 -0
  24. hugpy_storage/hf_token.py +228 -0
  25. hugpy_storage/huggingface_api.py +240 -0
  26. hugpy_storage/hugpy_marker.py +811 -0
  27. hugpy_storage/model_config_shim.py +16 -0
  28. hugpy_storage/model_metadata.py +640 -0
  29. hugpy_storage/model_paths.py +439 -0
  30. hugpy_storage/model_physical.py +614 -0
  31. hugpy_storage/model_presence.py +321 -0
  32. hugpy_storage/model_status_cache.py +413 -0
  33. hugpy_storage/model_sync.py +162 -0
  34. hugpy_storage/providers.py +151 -0
  35. hugpy_storage/provision.py +2143 -0
  36. hugpy_storage/py.typed +0 -0
  37. hugpy_storage/schemas/__init__.py +0 -0
  38. hugpy_storage/schemas/download_schemas.py +43 -0
  39. hugpy_storage-0.2.0a0.dist-info/METADATA +41 -0
  40. hugpy_storage-0.2.0a0.dist-info/RECORD +44 -0
  41. hugpy_storage-0.2.0a0.dist-info/WHEEL +5 -0
  42. hugpy_storage-0.2.0a0.dist-info/entry_points.txt +3 -0
  43. hugpy_storage-0.2.0a0.dist-info/licenses/LICENSE +41 -0
  44. hugpy_storage-0.2.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,56 @@
1
+ """hugpy_storage — bytes at rest and in transit.
2
+
3
+ The download queue and daemon (``downloader``), resumable central/HF transfer
4
+ (``provision``, ``model_sync``, ``download_models``), Hugging Face transport
5
+ and token (``huggingface_api``, ``hf_token``, ``model_metadata``), physical
6
+ inventory and its caches (``model_physical``, ``model_status_cache``,
7
+ ``model_presence``), the on-disk model layout (``model_paths``,
8
+ ``hugpy_marker``, ``gguf_inspect``) and the console-side helpers.
9
+
10
+ Seams to the layers above (installed by the composition root):
11
+
12
+ * :mod:`hugpy_storage.catalog_source` — the model registry Protocol
13
+ (``set_catalog_source``); null default knows no models.
14
+ * :mod:`hugpy_storage.providers` — budget gate, transfer telemetry, executor
15
+ registrar, serve-path hook, footprint selector.
16
+ * :mod:`hugpy_storage.events` — ``catalog.changed`` on the control bus after
17
+ the inventory moves.
18
+
19
+ This ``__init__`` is deliberately light: nothing heavy (huggingface_hub,
20
+ requests, pydantic, sqlite stores) is imported here.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from hugpy_storage.catalog_source import (
25
+ CatalogSource,
26
+ DictCatalogSource,
27
+ NullCatalogSource,
28
+ get_catalog_source,
29
+ set_catalog_source,
30
+ )
31
+ from hugpy_storage.events import publish_catalog_changed
32
+ from hugpy_storage.providers import (
33
+ set_budget_gate,
34
+ set_executor_registrar,
35
+ set_footprint_selector,
36
+ set_serve_path_hook,
37
+ set_transfer_telemetry,
38
+ )
39
+
40
+ try: # the installed distribution's version: the workspace tag/commit, never a literal
41
+ from importlib.metadata import version as _dist_version
42
+ __version__ = _dist_version("hugpy-storage")
43
+ except Exception: # noqa: BLE001 — source tree without metadata
44
+ __version__ = "0.0.0+unknown"
45
+
46
+ __all__ = [
47
+ "__version__",
48
+ # catalog seam
49
+ "CatalogSource", "NullCatalogSource", "DictCatalogSource",
50
+ "set_catalog_source", "get_catalog_source",
51
+ # upper-layer hooks
52
+ "set_budget_gate", "set_transfer_telemetry", "set_executor_registrar",
53
+ "set_serve_path_hook", "set_footprint_selector",
54
+ # events
55
+ "publish_catalog_changed",
56
+ ]
@@ -0,0 +1,435 @@
1
+ """Post-download ADMISSION — the storage half (record + queue + install hook).
2
+
3
+ A model that finishes downloading on central is not yet trusted to serve. The
4
+ admission gate (``hugpy_ops.admission``) runs, in order, the static integrity
5
+ audit, then the HugPy-native benchmark, and writes the verdict onto the
6
+ model's own record:
7
+
8
+ hugpy.json["admission"] = {
9
+ "status": "pending" | "admitted" | "held",
10
+ "reason": str | None, # why held / the note when admitted
11
+ "integrity": str | None, # the static audit verdict
12
+ "grade": float | None, # aptitude grade the benchmark produced
13
+ "at": iso-8601 UTC,
14
+ "job": str | None, # admission job id (this module's queue)
15
+ # optional (present only when recorded):
16
+ "failure_class": str, # the benchmark row's class (no_lane, timeout, ...)
17
+ "evidence": dict, # that row's evidence, verbatim
18
+ "blocked_on": str, # no_lane: placement | eligible_worker | lane
19
+ "elapsed_s": float, # wall seconds the admission attempt took
20
+ "failures": list, # every failed row {failure_class, worker, quant, reason}
21
+ "run_id": str, # benchmark run, when no row named the model
22
+ }
23
+
24
+ hugpy.json is the model's record, so the admission lives there — one source.
25
+ Central's resolver reads it (through the persisted marker aspect every catalog
26
+ surface already reads) and refuses a ``held`` model; ``pending`` routes, so the
27
+ benchmark that decides admission can reach the model.
28
+
29
+ This module owns the parts that have to live BELOW the engine:
30
+
31
+ * :func:`write_admission` / :func:`read_admission` — atomic read-modify-write of
32
+ the block on an existing marker (the marker writer's own ``_save_marker``).
33
+ * :class:`AdmissionQueue` — a small persistent queue (sqlite in PROJECTS_HOME)
34
+ shared by every process on central. The download daemon's transfer child
35
+ enqueues; the server's admission runner (one per host, flock-elected)
36
+ claims. Idempotent per ``(model_key, captured_at)`` — a re-stamp that
37
+ carries the install manifest over never re-admits.
38
+ * :func:`on_install_complete` — THE hook, called by every download path that
39
+ writes a ``source: download`` marker, after the dir is promoted. Central
40
+ only (a worker never downloads from the Hub and never admits). Never raises.
41
+
42
+ No network, ever: stdlib + the marker module.
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import json
47
+ import logging
48
+ import os
49
+ import sqlite3
50
+ import time
51
+ import uuid
52
+ from contextlib import contextmanager
53
+ from datetime import datetime, timezone
54
+ from typing import Any, Optional
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+ ADMISSION_KEY = "admission"
59
+ PENDING, ADMITTED, HELD = "pending", "admitted", "held"
60
+ STATUSES = (PENDING, ADMITTED, HELD)
61
+
62
+ # Queue row states (the JOB, not the model's admission).
63
+ Q_QUEUED, Q_RUNNING, Q_DONE, Q_FAILED = "queued", "running", "done", "failed"
64
+
65
+ DB_ENV = "HUGPY_ADMISSION_DB"
66
+ OFF_ENV = "HUGPY_ADMISSION" # "off" disables the install hook
67
+ DB_BASENAME = "admission_queue.sqlite"
68
+ MAX_ATTEMPTS = 3 # restarts a job may survive before it is failed
69
+
70
+
71
+ def utc_now_iso() -> str:
72
+ return datetime.now(timezone.utc).isoformat()
73
+
74
+
75
+ def admission_record(status: str, *, reason: Optional[str] = None,
76
+ integrity: Optional[str] = None, grade: Optional[float] = None,
77
+ job: Optional[str] = None, at: Optional[str] = None,
78
+ **extra: Any) -> dict:
79
+ """The admission block, schema-complete (every key present)."""
80
+ if status not in STATUSES:
81
+ raise ValueError(f"admission status must be one of {STATUSES}, not {status!r}")
82
+ rec = {"status": status, "reason": reason, "integrity": integrity,
83
+ "grade": None if grade is None else float(grade),
84
+ "at": at or utc_now_iso(), "job": job}
85
+ rec.update({k: v for k, v in extra.items() if v is not None})
86
+ return rec
87
+
88
+
89
+ def read_admission(directory: Optional[str]) -> Optional[dict]:
90
+ """The admission block on ``directory``'s hugpy.json, or None."""
91
+ if not directory:
92
+ return None
93
+ from hugpy_storage.hugpy_marker import read_hugpy_marker
94
+ marker = read_hugpy_marker(directory)
95
+ block = (marker or {}).get(ADMISSION_KEY)
96
+ return block if isinstance(block, dict) else None
97
+
98
+
99
+ def write_admission(directory: Optional[str], record: dict, *,
100
+ model_key: Optional[str] = None) -> Optional[str]:
101
+ """Set the admission block on an EXISTING hugpy.json (atomic temp+replace).
102
+
103
+ Returns the marker path, or None when there is no marker to extend (the
104
+ record then lives only on the queue row — a dir without a marker is not
105
+ an installed model). ``model_key`` drops that model's persisted physical
106
+ record so every catalog surface re-reads the marker on its next look."""
107
+ if not directory:
108
+ return None
109
+ from hugpy_storage.hugpy_marker import _save_marker, read_hugpy_marker
110
+ marker = read_hugpy_marker(directory)
111
+ if not isinstance(marker, dict):
112
+ return None
113
+ marker[ADMISSION_KEY] = dict(record)
114
+ path = _save_marker(directory, marker)
115
+ if model_key:
116
+ try:
117
+ from hugpy_storage.model_physical import forget_physical
118
+ forget_physical(model_key, f"admission {record.get('status')}")
119
+ except Exception: # noqa: BLE001 — the record landed; the cache re-derives
120
+ logger.debug("admission: physical-record invalidation failed for %s",
121
+ model_key, exc_info=True)
122
+ return path
123
+
124
+
125
+ def held_reason(block: Optional[dict]) -> Optional[str]:
126
+ """The refusal text for a ``held`` block, else None."""
127
+ if not isinstance(block, dict) or block.get("status") != HELD:
128
+ return None
129
+ return str(block.get("reason") or "held by admission (no reason recorded)")
130
+
131
+
132
+ # ── the queue ────────────────────────────────────────────────────────────────
133
+
134
+ def default_db_path() -> str:
135
+ env = (os.environ.get(DB_ENV) or "").strip()
136
+ if env:
137
+ return env
138
+ from hugpy_platform.constants import PROJECTS_HOME
139
+ return os.path.join(str(PROJECTS_HOME), DB_BASENAME)
140
+
141
+
142
+ _SCHEMA = """
143
+ CREATE TABLE IF NOT EXISTS admission_jobs (
144
+ id TEXT PRIMARY KEY,
145
+ model_key TEXT NOT NULL,
146
+ directory TEXT,
147
+ captured_at TEXT NOT NULL,
148
+ source TEXT,
149
+ status TEXT NOT NULL,
150
+ owner TEXT,
151
+ created_at REAL NOT NULL,
152
+ started_at REAL,
153
+ finished_at REAL,
154
+ result TEXT,
155
+ log TEXT,
156
+ attempt INTEGER NOT NULL DEFAULT 0,
157
+ UNIQUE (model_key, captured_at)
158
+ );
159
+ CREATE INDEX IF NOT EXISTS admission_jobs_status ON admission_jobs (status, created_at);
160
+ """
161
+
162
+
163
+ class AdmissionQueue:
164
+ """Persistent, cross-process admission job queue (sqlite, WAL)."""
165
+
166
+ def __init__(self, path: Optional[str] = None) -> None:
167
+ self._path = path
168
+ self._ready = False
169
+
170
+ @property
171
+ def path(self) -> str:
172
+ return self._path or default_db_path()
173
+
174
+ def _connect(self) -> sqlite3.Connection:
175
+ parent = os.path.dirname(self.path)
176
+ if parent:
177
+ os.makedirs(parent, exist_ok=True)
178
+ conn = sqlite3.connect(self.path, timeout=30, isolation_level=None)
179
+ conn.row_factory = sqlite3.Row
180
+ # busy_timeout makes EVERY statement (pragmas included) wait for a lock
181
+ # instead of failing at once; the connect() timeout alone does not cover
182
+ # them. Live incident 2026-09-23: the install hook lost dreamshaper-8's
183
+ # job with "database is locked" because the console's list() and the
184
+ # hook opened the file together and each ran the WAL switch + schema.
185
+ conn.execute("PRAGMA busy_timeout=30000")
186
+ if not self._ready:
187
+ # Switching the journal mode needs an exclusive lock and the schema
188
+ # script is a write transaction; do both ONCE per process, and never
189
+ # let a lost race here break a caller that only wants to read/enqueue.
190
+ try:
191
+ mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
192
+ if str(mode).lower() != "wal":
193
+ conn.execute("PRAGMA journal_mode=WAL")
194
+ conn.executescript(_SCHEMA)
195
+ cols = {r[1] for r in conn.execute("PRAGMA table_info(admission_jobs)")}
196
+ if "attempt" not in cols: # queues created before 2026-09-23 restart-survival
197
+ conn.execute("ALTER TABLE admission_jobs ADD COLUMN attempt "
198
+ "INTEGER NOT NULL DEFAULT 0")
199
+ self._ready = True
200
+ except sqlite3.OperationalError as exc:
201
+ if "locked" not in str(exc).lower():
202
+ raise
203
+ return conn
204
+
205
+ @contextmanager
206
+ def _db(self):
207
+ conn = self._connect()
208
+ try:
209
+ yield conn
210
+ finally:
211
+ conn.close()
212
+
213
+ @staticmethod
214
+ def _row(r: Optional[sqlite3.Row]) -> Optional[dict]:
215
+ if r is None:
216
+ return None
217
+ d = dict(r)
218
+ for k in ("result",):
219
+ if d.get(k):
220
+ try:
221
+ d[k] = json.loads(d[k])
222
+ except ValueError:
223
+ pass
224
+ return d
225
+
226
+ def enqueue(self, model_key: str, captured_at: str, *,
227
+ directory: Optional[str] = None, source: str = "download") -> tuple:
228
+ """``(job_row, created)``. A second enqueue of the same
229
+ ``(model_key, captured_at)`` returns the existing row, created=False."""
230
+ if not model_key or not captured_at:
231
+ raise ValueError("model_key and captured_at are required")
232
+ job_id = uuid.uuid4().hex[:16]
233
+ with self._db() as conn:
234
+ cur = conn.execute(
235
+ "INSERT OR IGNORE INTO admission_jobs (id, model_key, directory, "
236
+ "captured_at, source, status, created_at) VALUES (?,?,?,?,?,?,?)",
237
+ (job_id, model_key, directory, str(captured_at), source,
238
+ Q_QUEUED, time.time()))
239
+ created = cur.rowcount == 1
240
+ row = conn.execute(
241
+ "SELECT * FROM admission_jobs WHERE model_key=? AND captured_at=?",
242
+ (model_key, str(captured_at))).fetchone()
243
+ return self._row(row), created
244
+
245
+ def claim_next(self, owner: str) -> Optional[dict]:
246
+ """Atomically take the oldest queued job (compare-and-set)."""
247
+ with self._db() as conn:
248
+ conn.execute("BEGIN IMMEDIATE")
249
+ try:
250
+ row = conn.execute(
251
+ "SELECT * FROM admission_jobs WHERE status=? "
252
+ "ORDER BY created_at LIMIT 1", (Q_QUEUED,)).fetchone()
253
+ if row is None:
254
+ conn.execute("COMMIT")
255
+ return None
256
+ conn.execute(
257
+ "UPDATE admission_jobs SET status=?, owner=?, started_at=? "
258
+ "WHERE id=? AND status=?",
259
+ (Q_RUNNING, owner, time.time(), row["id"], Q_QUEUED))
260
+ conn.execute("COMMIT")
261
+ except BaseException:
262
+ conn.execute("ROLLBACK")
263
+ raise
264
+ return self.get(row["id"])
265
+
266
+ def requeue_stale(self, older_than_s: float = 6 * 3600) -> int:
267
+ """Put jobs a dead runner left ``running`` back on the queue."""
268
+ cutoff = time.time() - older_than_s
269
+ with self._db() as conn:
270
+ cur = conn.execute(
271
+ "UPDATE admission_jobs SET status=?, owner=NULL WHERE status=? "
272
+ "AND started_at < ?", (Q_QUEUED, Q_RUNNING, cutoff))
273
+ return cur.rowcount
274
+
275
+ def requeue_orphaned(self, reason: str = "central restarted",
276
+ max_attempts: int = MAX_ATTEMPTS) -> dict:
277
+ """Called by a NEWLY ELECTED runner: the runner flock is held for the
278
+ life of the process that runs jobs, so every row still ``running`` at
279
+ election belongs to a runner that died (a central restart). Each goes
280
+ back to ``queued`` with ``attempt + 1`` and a log line; a job already
281
+ interrupted ``max_attempts`` times is ``failed`` with the reason instead
282
+ of looping forever (the model's admission stays pending; rerun it)."""
283
+ stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
284
+ out = {"requeued": [], "failed": []}
285
+ with self._db() as conn:
286
+ conn.execute("BEGIN IMMEDIATE")
287
+ try:
288
+ rows = conn.execute("SELECT id, attempt, log FROM admission_jobs WHERE status=?",
289
+ (Q_RUNNING,)).fetchall()
290
+ for r in rows:
291
+ attempt = int(r["attempt"] or 0) + 1
292
+ prior = (r["log"] + "\n") if r["log"] else ""
293
+ if attempt > max_attempts:
294
+ line = (f"{stamp} interrupted ({reason}) {attempt - 1}x — not re-queued "
295
+ f"again (max {max_attempts}); POST /llm/admission/<model>/rerun")
296
+ conn.execute(
297
+ "UPDATE admission_jobs SET status=?, owner=NULL, attempt=?, finished_at=?, "
298
+ "result=?, log=? WHERE id=? AND status=?",
299
+ (Q_FAILED, attempt, time.time(),
300
+ json.dumps({"status": "failed", "reason": line[len(stamp) + 1:]}),
301
+ prior + line, r["id"], Q_RUNNING))
302
+ out["failed"].append(r["id"])
303
+ else:
304
+ line = f"{stamp} re-queued: {reason} while running (attempt {attempt})"
305
+ conn.execute(
306
+ "UPDATE admission_jobs SET status=?, owner=NULL, started_at=NULL, "
307
+ "attempt=?, log=? WHERE id=? AND status=?",
308
+ (Q_QUEUED, attempt, prior + line, r["id"], Q_RUNNING))
309
+ out["requeued"].append(r["id"])
310
+ conn.execute("COMMIT")
311
+ except BaseException:
312
+ conn.execute("ROLLBACK")
313
+ raise
314
+ return out
315
+
316
+ def requeue_owner(self, owner: str) -> int:
317
+ """A restarted runner re-queues what its previous incarnation held."""
318
+ with self._db() as conn:
319
+ cur = conn.execute(
320
+ "UPDATE admission_jobs SET status=?, owner=NULL WHERE status=? AND owner=?",
321
+ (Q_QUEUED, Q_RUNNING, owner))
322
+ return cur.rowcount
323
+
324
+ def finish(self, job_id: str, status: str, *, result: Optional[dict] = None,
325
+ log: Optional[list] = None) -> None:
326
+ with self._db() as conn:
327
+ conn.execute(
328
+ "UPDATE admission_jobs SET status=?, finished_at=?, result=?, "
329
+ "log=COALESCE(?, log) WHERE id=?",
330
+ (status, time.time(), json.dumps(result, default=str) if result is not None else None,
331
+ "\n".join(log) if log else None, job_id))
332
+
333
+ def append_log(self, job_id: str, line: str) -> None:
334
+ with self._db() as conn:
335
+ row = conn.execute("SELECT log FROM admission_jobs WHERE id=?", (job_id,)).fetchone()
336
+ prior = (row["log"] + "\n") if row and row["log"] else ""
337
+ conn.execute("UPDATE admission_jobs SET log=? WHERE id=?", (prior + line, job_id))
338
+
339
+ def get(self, job_id: str) -> Optional[dict]:
340
+ with self._db() as conn:
341
+ return self._row(conn.execute(
342
+ "SELECT * FROM admission_jobs WHERE id=?", (job_id,)).fetchone())
343
+
344
+ def latest_for(self, model_key: str) -> Optional[dict]:
345
+ with self._db() as conn:
346
+ return self._row(conn.execute(
347
+ "SELECT * FROM admission_jobs WHERE model_key=? ORDER BY created_at DESC LIMIT 1",
348
+ (model_key,)).fetchone())
349
+
350
+ def list(self, *, status: Optional[str] = None, limit: int = 500) -> list:
351
+ q, args = "SELECT * FROM admission_jobs", []
352
+ if status:
353
+ q += " WHERE status=?"
354
+ args.append(status)
355
+ q += " ORDER BY created_at DESC LIMIT ?"
356
+ args.append(int(limit))
357
+ with self._db() as conn:
358
+ return [self._row(r) for r in conn.execute(q, args).fetchall()]
359
+
360
+
361
+ admission_queue = AdmissionQueue()
362
+
363
+
364
+ def hook_enabled() -> bool:
365
+ """The install hook runs on CENTRAL only, and can be switched off."""
366
+ if (os.environ.get(OFF_ENV) or "").strip().lower() in ("0", "off", "false", "no"):
367
+ return False
368
+ try:
369
+ from hugpy_storage.provision import worker_central_url
370
+ return worker_central_url() is None
371
+ except Exception: # noqa: BLE001
372
+ return True
373
+
374
+
375
+ def on_install_complete(directory: str, model_key: Optional[str] = None, *,
376
+ source: str = "download", captured_at: Optional[str] = None,
377
+ queue: Optional[AdmissionQueue] = None) -> Optional[dict]:
378
+ """THE install hook: enqueue one admission job for the model now at
379
+ ``directory`` and mark it ``pending`` on its marker.
380
+
381
+ Keys: the marker's declared ``name`` (what discovery keys the catalog on)
382
+ wins over the caller's ``model_key`` (a repo-download job id such as
383
+ ``owner_repo``); the runner re-resolves against the catalog by directory.
384
+ ``captured_at`` defaults to the marker's install-manifest capture time —
385
+ that is what makes the enqueue idempotent per install. Returns the job row,
386
+ or None when nothing was enqueued. NEVER raises: a completed download must
387
+ not fail over its admission bookkeeping."""
388
+ try:
389
+ if not hook_enabled():
390
+ return None
391
+ from hugpy_storage.hugpy_marker import MANIFEST_KEY, read_hugpy_marker
392
+ marker = read_hugpy_marker(directory) if directory else None
393
+ key = ((marker or {}).get("name") or model_key or "").strip()
394
+ if not key:
395
+ return None
396
+ stamp = captured_at or ((marker or {}).get(MANIFEST_KEY) or {}).get("captured_at") \
397
+ or (marker or {}).get("stamped_at")
398
+ if not stamp:
399
+ return None
400
+ q = queue or admission_queue
401
+ job, created = q.enqueue(key, str(stamp), directory=directory, source=source)
402
+ if created and marker is not None:
403
+ write_admission(directory, admission_record(
404
+ PENDING, reason="admission queued after download", job=job["id"]),
405
+ model_key=key)
406
+ logger.info("admission: %s job %s for %s (%s)",
407
+ "queued" if created else "already queued", job["id"], key, directory)
408
+ return job
409
+ except Exception as exc: # noqa: BLE001
410
+ logger.warning("admission hook failed for %s (%s): %s", model_key, directory, exc)
411
+ return None
412
+
413
+
414
+ def request_admission(model_key: str, directory: Optional[str], *,
415
+ source: str = "rerun", reason: str = "admission re-run requested",
416
+ queue: Optional[AdmissionQueue] = None) -> dict:
417
+ """Queue a fresh admission job for an installed model (operator re-run,
418
+ resweep). Unlike the install hook this always creates a job — its
419
+ ``captured_at`` is ``<source>:<now>`` — and it raises on failure (the
420
+ caller is an operator surface that must report it)."""
421
+ q = queue or admission_queue
422
+ job, _created = q.enqueue(model_key, f"{source}:{utc_now_iso()}",
423
+ directory=directory, source=source)
424
+ write_admission(directory, admission_record(PENDING, reason=reason, job=job["id"]),
425
+ model_key=model_key)
426
+ return job
427
+
428
+
429
+ __all__ = [
430
+ "ADMISSION_KEY", "PENDING", "ADMITTED", "HELD", "STATUSES",
431
+ "Q_QUEUED", "Q_RUNNING", "Q_DONE", "Q_FAILED",
432
+ "admission_record", "read_admission", "write_admission", "held_reason",
433
+ "AdmissionQueue", "admission_queue", "default_db_path",
434
+ "hook_enabled", "on_install_complete", "request_admission", "utc_now_iso",
435
+ ]
@@ -0,0 +1,136 @@
1
+ """The ARCHIVE MARK — an operator's "retire this model" flag on its own record.
2
+
3
+ Two states only: LIVE or ARCHIVE (operator ruling 2026-09-23). The mark is the
4
+ operator's recorded intent to move a live model into the archive; the sweep
5
+ (``hugpy_ops.model_archive`` / ``hugpy-model-archive --apply``) is what turns
6
+ the intent into the archive state (the directory moved under
7
+ ``ARCHIVE/MODELS_ARCHIVED-<date>/``, a MANIFEST row with the restore command,
8
+ the catalog row removed). Marking deletes, moves and evicts NOTHING.
9
+
10
+ The mark lives on the model's hugpy.json, beside ``admission``:
11
+
12
+ hugpy.json["archive"] = {
13
+ "marked": true,
14
+ "at": iso-8601 UTC, # when it was marked
15
+ "by": str, # who marked it (operator username / key name / 'console')
16
+ "reason": str | None, # the operator's words, verbatim; None when none given
17
+ }
18
+
19
+ Unmarking removes the block (the marker is the record of CURRENT intent; the
20
+ server audit log keeps the history of mark/unmark events).
21
+
22
+ While marked, central refuses the model everywhere a placement or routing
23
+ choice could land on it: new designations (assign / load / bulk alloc / group
24
+ allocate / template activate / placement preference), warm + provisioning
25
+ sweeps, and the resolver (``hugpy_fleet.central.archive_gate``). Every refusal
26
+ carries :func:`archive_text` — the recorded facts, never a canned line.
27
+
28
+ stdlib + the marker module; no network.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ from datetime import datetime, timezone
34
+ from typing import Optional
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ ARCHIVE_KEY = "archive"
39
+ # The phrase every refusal carries (the engine's cold-hold classifier keys on
40
+ # it as PERMANENT: fail fast, never held/retried, never cached as a load verdict).
41
+ ARCHIVE_MARKER = "marked for archive"
42
+
43
+
44
+ def utc_now_iso() -> str:
45
+ return datetime.now(timezone.utc).isoformat()
46
+
47
+
48
+ def archive_record(*, by: str, reason: Optional[str] = None,
49
+ at: Optional[str] = None) -> dict:
50
+ """The archive block, schema-complete (every key present)."""
51
+ r = (reason or "").strip() if isinstance(reason, str) else None
52
+ return {"marked": True, "at": at or utc_now_iso(),
53
+ "by": str(by or "").strip() or "console", "reason": r or None}
54
+
55
+
56
+ def is_marked(block: Optional[dict]) -> bool:
57
+ return isinstance(block, dict) and bool(block.get("marked"))
58
+
59
+
60
+ def archive_view(block: Optional[dict]) -> dict:
61
+ """The ``archived`` projection every surface exposes:
62
+ ``{marked, at, by, reason}`` (``marked: false`` + nulls when unmarked)."""
63
+ if not is_marked(block):
64
+ return {"marked": False, "at": None, "by": None, "reason": None}
65
+ return {"marked": True, "at": block.get("at"), "by": block.get("by"),
66
+ "reason": block.get("reason")}
67
+
68
+
69
+ def archive_text(block: Optional[dict]) -> Optional[str]:
70
+ """``marked for archive by <by> at <at>: <reason>`` from the recorded block,
71
+ or None when not marked. A mark recorded without a reason says so."""
72
+ if not is_marked(block):
73
+ return None
74
+ reason = block.get("reason")
75
+ tail = f": {reason}" if reason else " (no reason was given when it was marked)"
76
+ return f"{ARCHIVE_MARKER} by {block.get('by')} at {block.get('at')}{tail}"
77
+
78
+
79
+ def read_archive_mark(directory: Optional[str]) -> Optional[dict]:
80
+ """The archive block on ``directory``'s hugpy.json, or None."""
81
+ if not directory:
82
+ return None
83
+ from hugpy_storage.hugpy_marker import read_hugpy_marker
84
+ block = (read_hugpy_marker(directory) or {}).get(ARCHIVE_KEY)
85
+ return block if isinstance(block, dict) else None
86
+
87
+
88
+ def _forget(model_key: Optional[str], why: str) -> None:
89
+ if not model_key:
90
+ return
91
+ try:
92
+ from hugpy_storage.model_physical import forget_physical
93
+ forget_physical(model_key, why)
94
+ except Exception: # noqa: BLE001 — the record landed; the cache re-derives
95
+ logger.debug("archive mark: physical-record invalidation failed for %s",
96
+ model_key, exc_info=True)
97
+
98
+
99
+ def write_archive_mark(directory: Optional[str], record: dict, *,
100
+ model_key: Optional[str] = None) -> Optional[str]:
101
+ """Set the archive block on an EXISTING hugpy.json (atomic temp+replace).
102
+
103
+ Returns the marker path, or None when the dir carries no marker (a dir
104
+ without a marker is not an installed model — nothing to mark). Drops the
105
+ model's persisted physical record so every catalog surface re-reads it."""
106
+ if not directory:
107
+ return None
108
+ from hugpy_storage.hugpy_marker import _save_marker, read_hugpy_marker
109
+ marker = read_hugpy_marker(directory)
110
+ if not isinstance(marker, dict):
111
+ return None
112
+ marker[ARCHIVE_KEY] = dict(record)
113
+ path = _save_marker(directory, marker)
114
+ _forget(model_key, f"archive marked by {record.get('by')}")
115
+ return path
116
+
117
+
118
+ def clear_archive_mark(directory: Optional[str], *,
119
+ model_key: Optional[str] = None) -> Optional[dict]:
120
+ """Remove the archive block. Returns the block that was removed, or None
121
+ when there was none (or no marker)."""
122
+ if not directory:
123
+ return None
124
+ from hugpy_storage.hugpy_marker import _save_marker, read_hugpy_marker
125
+ marker = read_hugpy_marker(directory)
126
+ if not isinstance(marker, dict) or ARCHIVE_KEY not in marker:
127
+ return None
128
+ was = marker.pop(ARCHIVE_KEY)
129
+ _save_marker(directory, marker)
130
+ _forget(model_key, "archive mark cleared")
131
+ return was if isinstance(was, dict) else None
132
+
133
+
134
+ __all__ = ["ARCHIVE_KEY", "ARCHIVE_MARKER", "archive_record", "archive_text",
135
+ "archive_view", "clear_archive_mark", "is_marked", "read_archive_mark",
136
+ "write_archive_mark"]