P0-R2 HF Bucket root-path correction

#136
Files changed (4) hide show
  1. hf_bucket_store.py +380 -0
  2. managed_auth.py +47 -0
  3. requirements.txt +2 -0
  4. task_executor.py +24 -1
hf_bucket_store.py ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Best-effort private Hugging Face Bucket storage for completed ZIP delivery.
3
+
4
+ This module is deliberately outside the paper-generation core. Bucket storage is
5
+ an optional recovery layer only: a Bucket error must never turn an already valid
6
+ local paper package into a failed generation or alter quota/accounting state.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import logging
13
+ import os
14
+ import re
15
+ import tempfile
16
+ import zipfile
17
+ from dataclasses import dataclass
18
+ from datetime import datetime, timedelta, timezone
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ _TRUE = {"1", "true", "yes", "on", "enabled"}
23
+ _DEFAULT_TTL_HOURS = 168
24
+ _DEFAULT_WARNING_GIB = 70.0
25
+ _DEFAULT_HARD_STOP_GIB = 80.0
26
+ _MAX_HARD_STOP_GIB = 80.0 # Free-only safety ceiling approved for Article Agent.
27
+ _GIB = 1024 ** 3
28
+ _TASK_RE = re.compile(r"^[A-Za-z0-9_.-]{1,160}$")
29
+ _BUCKET_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class BucketConfig:
34
+ enabled: bool
35
+ bucket_id: str
36
+ token: str
37
+ ttl_hours: int
38
+ warning_bytes: int
39
+ hard_stop_bytes: int
40
+
41
+
42
+ def _truthy(value: Any) -> bool:
43
+ return str(value or "").strip().casefold() in _TRUE
44
+
45
+
46
+ def _bounded_float(name: str, default: float, low: float, high: float) -> float:
47
+ try:
48
+ value = float(os.environ.get(name, str(default)))
49
+ except (TypeError, ValueError):
50
+ value = default
51
+ return max(low, min(high, value))
52
+
53
+
54
+ def _bounded_int(name: str, default: int, low: int, high: int) -> int:
55
+ try:
56
+ value = int(os.environ.get(name, str(default)))
57
+ except (TypeError, ValueError):
58
+ value = default
59
+ return max(low, min(high, value))
60
+
61
+
62
+ def config() -> BucketConfig:
63
+ enabled = _truthy(os.environ.get("SMARTPAPER_HF_ARTIFACTS_ENABLED"))
64
+ bucket_id = str(os.environ.get("SMARTPAPER_HF_ARTIFACT_BUCKET") or "").strip()
65
+ token = str(os.environ.get("SMARTPAPER_HF_BUCKET_TOKEN") or "").strip()
66
+ ttl_hours = _bounded_int("SMARTPAPER_HF_ARTIFACT_TTL_HOURS", _DEFAULT_TTL_HOURS, 1, _DEFAULT_TTL_HOURS)
67
+ requested_hard = _bounded_float(
68
+ "SMARTPAPER_HF_ARTIFACT_HARD_STOP_GIB", _DEFAULT_HARD_STOP_GIB, 1.0, _MAX_HARD_STOP_GIB
69
+ )
70
+ requested_warning = _bounded_float(
71
+ "SMARTPAPER_HF_ARTIFACT_WARNING_GIB", _DEFAULT_WARNING_GIB, 1.0, requested_hard
72
+ )
73
+ return BucketConfig(
74
+ enabled=enabled,
75
+ bucket_id=bucket_id,
76
+ token=token,
77
+ ttl_hours=ttl_hours,
78
+ warning_bytes=int(requested_warning * _GIB),
79
+ hard_stop_bytes=int(requested_hard * _GIB),
80
+ )
81
+
82
+
83
+ def _new_api(token: str):
84
+ # huggingface_hub reads these at import time. Keep recovery bounded without
85
+ # importing the optional dependency when the feature is disabled.
86
+ os.environ.setdefault("HF_HUB_ETAG_TIMEOUT", "8")
87
+ os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "8")
88
+ from huggingface_hub import HfApi # lazy: absence must not break normal generation
89
+
90
+ return HfApi(token=token)
91
+
92
+
93
+ def _validate_config(cfg: BucketConfig) -> None:
94
+ if not cfg.enabled:
95
+ raise RuntimeError("hf_bucket_disabled")
96
+ if not cfg.bucket_id or not _BUCKET_RE.fullmatch(cfg.bucket_id):
97
+ raise RuntimeError("hf_bucket_id_invalid")
98
+ if not cfg.token:
99
+ raise RuntimeError("hf_bucket_token_missing")
100
+ if cfg.hard_stop_bytes > int(_MAX_HARD_STOP_GIB * _GIB):
101
+ raise RuntimeError("hf_bucket_hard_stop_exceeds_free_safety_ceiling")
102
+
103
+
104
+ def _trusted_key_parts(user_id: int | str, task_id: str) -> tuple[str, str]:
105
+ try:
106
+ trusted_user_id = str(int(user_id))
107
+ except (TypeError, ValueError) as exc:
108
+ raise RuntimeError("hf_bucket_user_id_invalid") from exc
109
+ trusted_task_id = str(task_id or "").strip()
110
+ if not _TASK_RE.fullmatch(trusted_task_id):
111
+ raise RuntimeError("hf_bucket_task_id_invalid")
112
+ return trusted_user_id, trusted_task_id
113
+
114
+
115
+ def object_paths(user_id: int | str, task_id: str) -> tuple[str, str]:
116
+ trusted_user_id, trusted_task_id = _trusted_key_parts(user_id, task_id)
117
+ prefix = f"v1/users/{trusted_user_id}/tasks/{trusted_task_id}"
118
+ return f"{prefix}/final.zip", f"{prefix}/final.json"
119
+
120
+
121
+ def _safe_output_filename(value: str) -> str:
122
+ text = str(value or "").strip()
123
+ name = os.path.basename(text)
124
+ if not name or name != text or not name.lower().endswith(".zip") or len(name) > 240:
125
+ raise RuntimeError("hf_bucket_output_filename_invalid")
126
+ return name
127
+
128
+
129
+ def _sha256_file(path: Path) -> tuple[str, int]:
130
+ digest = hashlib.sha256()
131
+ size = 0
132
+ with path.open("rb") as handle:
133
+ for block in iter(lambda: handle.read(1024 * 1024), b""):
134
+ size += len(block)
135
+ digest.update(block)
136
+ return digest.hexdigest(), size
137
+
138
+
139
+ def _private_bucket_info(api, cfg: BucketConfig):
140
+ info = api.bucket_info(cfg.bucket_id)
141
+ if getattr(info, "private", None) is not True:
142
+ raise RuntimeError("hf_bucket_not_private")
143
+ return info
144
+
145
+
146
+ def _file_map(api, bucket_id: str, paths: list[str]) -> dict[str, Any]:
147
+ return {str(item.path): item for item in api.get_bucket_paths_info(bucket_id, paths)}
148
+
149
+
150
+ def _metadata_bytes(*, user_id: str, task_id: str, filename: str, sha256: str, size_bytes: int, uploaded_at: datetime) -> bytes:
151
+ payload = {
152
+ "schema_version": "articleagent-hf-bucket-v1",
153
+ "user_id": user_id,
154
+ "task_id": task_id,
155
+ "output_filename": filename,
156
+ "sha256": sha256,
157
+ "size_bytes": int(size_bytes),
158
+ "uploaded_at": uploaded_at.astimezone(timezone.utc).isoformat(),
159
+ }
160
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
161
+
162
+
163
+ def _parse_uploaded_at(value: Any) -> datetime:
164
+ text = str(value or "").strip()
165
+ if not text:
166
+ raise RuntimeError("hf_bucket_metadata_time_missing")
167
+ try:
168
+ observed = datetime.fromisoformat(text.replace("Z", "+00:00"))
169
+ except ValueError as exc:
170
+ raise RuntimeError("hf_bucket_metadata_time_invalid") from exc
171
+ if observed.tzinfo is None:
172
+ observed = observed.replace(tzinfo=timezone.utc)
173
+ return observed.astimezone(timezone.utc)
174
+
175
+
176
+ def _download_metadata(api, bucket_id: str, remote_path: str) -> dict[str, Any]:
177
+ with tempfile.TemporaryDirectory(prefix="sp_hf_meta_") as temp_dir:
178
+ target = Path(temp_dir) / "final.json"
179
+ api.download_bucket_files(bucket_id, [(remote_path, target)], raise_on_missing_files=True)
180
+ raw = target.read_bytes()
181
+ if len(raw) > 64 * 1024:
182
+ raise RuntimeError("hf_bucket_metadata_too_large")
183
+ value = json.loads(raw.decode("utf-8"))
184
+ if not isinstance(value, dict):
185
+ raise RuntimeError("hf_bucket_metadata_invalid")
186
+ return value
187
+
188
+
189
+ def cleanup_expired(*, api, cfg: BucketConfig, now: datetime | None = None) -> dict[str, Any]:
190
+ """Delete only files older than the approved 7-day retention window.
191
+
192
+ Cleanup itself is best-effort at the call site. This function raises on API
193
+ failure so callers can log it, while never guessing that a younger object is
194
+ safe to delete.
195
+ """
196
+ current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
197
+ cutoff = current - timedelta(hours=cfg.ttl_hours)
198
+ expired: list[str] = []
199
+ for item in api.list_bucket_tree(cfg.bucket_id, prefix="v1/users/", recursive=True):
200
+ if str(getattr(item, "type", "")) != "file":
201
+ continue
202
+ mtime = getattr(item, "mtime", None)
203
+ if not isinstance(mtime, datetime):
204
+ continue
205
+ if mtime.tzinfo is None:
206
+ mtime = mtime.replace(tzinfo=timezone.utc)
207
+ if mtime.astimezone(timezone.utc) < cutoff:
208
+ expired.append(str(item.path))
209
+ if expired:
210
+ api.batch_bucket_files(cfg.bucket_id, delete=expired)
211
+ return {"deleted": len(expired), "paths": expired}
212
+
213
+
214
+ def archive_completed_zip(
215
+ *,
216
+ user_id: int | str,
217
+ task_id: str,
218
+ output_filename: str,
219
+ output_path: str | Path,
220
+ now: datetime | None = None,
221
+ ) -> dict[str, Any]:
222
+ """Best-effort archive primitive. Caller must catch all errors.
223
+
224
+ This function never modifies task/quota state and should only be called after
225
+ the authoritative Worker has accepted the completed task transition.
226
+ """
227
+ cfg = config()
228
+ if not cfg.enabled:
229
+ return {"ok": True, "stored": False, "reason": "disabled"}
230
+ _validate_config(cfg)
231
+ trusted_user_id, trusted_task_id = _trusted_key_parts(user_id, task_id)
232
+ filename = _safe_output_filename(output_filename)
233
+ local = Path(output_path).resolve()
234
+ if not local.is_file() or local.name != filename:
235
+ raise RuntimeError("hf_bucket_local_zip_missing")
236
+ sha256, size_bytes = _sha256_file(local)
237
+ if size_bytes <= 0:
238
+ raise RuntimeError("hf_bucket_local_zip_empty")
239
+
240
+ api = _new_api(cfg.token)
241
+ _private_bucket_info(api, cfg)
242
+
243
+ cleanup_warning = ""
244
+ try:
245
+ cleanup_expired(api=api, cfg=cfg, now=now)
246
+ except Exception as exc: # cleanup must never destroy local delivery
247
+ cleanup_warning = type(exc).__name__
248
+ logging.warning("[SP_BUCKET] cleanup_failed task_id=%s error=%s", trusted_task_id, cleanup_warning)
249
+
250
+ zip_path, metadata_path = object_paths(trusted_user_id, trusted_task_id)
251
+ remote = _file_map(api, cfg.bucket_id, [zip_path, metadata_path])
252
+ if zip_path in remote or metadata_path in remote:
253
+ if zip_path in remote and metadata_path in remote:
254
+ existing = _download_metadata(api, cfg.bucket_id, metadata_path)
255
+ same = (
256
+ str(existing.get("user_id") or "") == trusted_user_id
257
+ and str(existing.get("task_id") or "") == trusted_task_id
258
+ and str(existing.get("output_filename") or "") == filename
259
+ and str(existing.get("sha256") or "") == sha256
260
+ and int(existing.get("size_bytes") or -1) == size_bytes
261
+ and int(getattr(remote[zip_path], "size", -1) or -1) == size_bytes
262
+ )
263
+ if same:
264
+ return {
265
+ "ok": True,
266
+ "stored": True,
267
+ "idempotent": True,
268
+ "sha256": sha256,
269
+ "size_bytes": size_bytes,
270
+ "cleanup_warning": cleanup_warning,
271
+ }
272
+ raise RuntimeError("hf_bucket_artifact_conflict")
273
+
274
+ info = _private_bucket_info(api, cfg)
275
+ current_size = int(getattr(info, "size", 0) or 0)
276
+ if current_size >= cfg.warning_bytes:
277
+ logging.warning("[SP_BUCKET] capacity_warning bytes=%s", current_size)
278
+ uploaded_at = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
279
+ metadata = _metadata_bytes(
280
+ user_id=trusted_user_id,
281
+ task_id=trusted_task_id,
282
+ filename=filename,
283
+ sha256=sha256,
284
+ size_bytes=size_bytes,
285
+ uploaded_at=uploaded_at,
286
+ )
287
+ projected = current_size + size_bytes + len(metadata)
288
+ if projected >= cfg.hard_stop_bytes:
289
+ raise RuntimeError("hf_bucket_hard_stop_reached")
290
+
291
+ api.batch_bucket_files(
292
+ cfg.bucket_id,
293
+ add=[(local, zip_path), (metadata, metadata_path)],
294
+ )
295
+ verified = _file_map(api, cfg.bucket_id, [zip_path, metadata_path])
296
+ if zip_path not in verified or metadata_path not in verified:
297
+ raise RuntimeError("hf_bucket_upload_incomplete")
298
+ if int(getattr(verified[zip_path], "size", -1) or -1) != size_bytes:
299
+ raise RuntimeError("hf_bucket_upload_size_mismatch")
300
+ existing = _download_metadata(api, cfg.bucket_id, metadata_path)
301
+ if (
302
+ str(existing.get("sha256") or "") != sha256
303
+ or int(existing.get("size_bytes") or -1) != size_bytes
304
+ or str(existing.get("task_id") or "") != trusted_task_id
305
+ or str(existing.get("user_id") or "") != trusted_user_id
306
+ ):
307
+ raise RuntimeError("hf_bucket_upload_metadata_mismatch")
308
+ return {
309
+ "ok": True,
310
+ "stored": True,
311
+ "idempotent": False,
312
+ "sha256": sha256,
313
+ "size_bytes": size_bytes,
314
+ "cleanup_warning": cleanup_warning,
315
+ }
316
+
317
+
318
+ def restore_completed_zip(
319
+ *,
320
+ user_id: int | str,
321
+ task_id: str,
322
+ output_filename: str,
323
+ output_path: str | Path,
324
+ now: datetime | None = None,
325
+ ) -> bool:
326
+ """Restore a completed ZIP from a private bucket, with ownership/integrity checks."""
327
+ cfg = config()
328
+ if not cfg.enabled:
329
+ return False
330
+ _validate_config(cfg)
331
+ trusted_user_id, trusted_task_id = _trusted_key_parts(user_id, task_id)
332
+ filename = _safe_output_filename(output_filename)
333
+ target = Path(output_path).resolve()
334
+ if target.is_file():
335
+ return True
336
+ if target.name != filename:
337
+ raise RuntimeError("hf_bucket_restore_target_invalid")
338
+
339
+ api = _new_api(cfg.token)
340
+ _private_bucket_info(api, cfg)
341
+ zip_path, metadata_path = object_paths(trusted_user_id, trusted_task_id)
342
+ remote = _file_map(api, cfg.bucket_id, [zip_path, metadata_path])
343
+ if zip_path not in remote or metadata_path not in remote:
344
+ return False
345
+
346
+ metadata = _download_metadata(api, cfg.bucket_id, metadata_path)
347
+ uploaded_at = _parse_uploaded_at(metadata.get("uploaded_at"))
348
+ current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
349
+ if uploaded_at > current + timedelta(minutes=5):
350
+ raise RuntimeError("hf_bucket_metadata_time_future")
351
+ if current - uploaded_at > timedelta(hours=cfg.ttl_hours):
352
+ return False
353
+ expected_size = int(metadata.get("size_bytes") or -1)
354
+ expected_sha = str(metadata.get("sha256") or "")
355
+ if (
356
+ str(metadata.get("schema_version") or "") != "articleagent-hf-bucket-v1"
357
+ or str(metadata.get("user_id") or "") != trusted_user_id
358
+ or str(metadata.get("task_id") or "") != trusted_task_id
359
+ or str(metadata.get("output_filename") or "") != filename
360
+ or expected_size <= 0
361
+ or not re.fullmatch(r"[0-9a-f]{64}", expected_sha)
362
+ or int(getattr(remote[zip_path], "size", -1) or -1) != expected_size
363
+ ):
364
+ raise RuntimeError("hf_bucket_restore_metadata_mismatch")
365
+
366
+ target.parent.mkdir(parents=True, exist_ok=True)
367
+ temporary = target.with_suffix(target.suffix + ".bucket.tmp")
368
+ try:
369
+ temporary.unlink(missing_ok=True)
370
+ api.download_bucket_files(cfg.bucket_id, [(zip_path, temporary)], raise_on_missing_files=True)
371
+ observed_sha, observed_size = _sha256_file(temporary)
372
+ if observed_size != expected_size or observed_sha != expected_sha:
373
+ raise RuntimeError("hf_bucket_restore_integrity_mismatch")
374
+ with zipfile.ZipFile(temporary, "r") as archive:
375
+ if archive.testzip() is not None:
376
+ raise RuntimeError("hf_bucket_restore_zip_invalid")
377
+ os.replace(temporary, target)
378
+ finally:
379
+ temporary.unlink(missing_ok=True)
380
+ return True
managed_auth.py CHANGED
@@ -14,15 +14,19 @@ import hmac
14
  import html
15
  import io
16
  import json
 
17
  import os
 
18
  import secrets
19
  import time
 
20
  from typing import Any, Callable
21
  from urllib.parse import unquote, urlsplit
22
 
23
  import qrcode
24
  import streamlit as st
25
 
 
26
  from i18n import tr
27
  from smartpaper_restore_bridge import edge_bridge
28
 
@@ -388,6 +392,48 @@ def start_generation_task(
388
  return dict(result)
389
 
390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  def recover_generation_task() -> dict[str, Any]:
392
  """Initialize current task state once from the authoritative Worker.
393
 
@@ -404,6 +450,7 @@ def recover_generation_task() -> dict[str, Any]:
404
  _merge_quota_into_session(result)
405
  task = result.get("task")
406
  if isinstance(task, dict) and task.get("task_id"):
 
407
  st.session_state["sp_generation_task"] = dict(task)
408
  st.session_state["sp_tracked_task_id"] = str(task["task_id"])
409
  st.session_state["sp_last_ui_task_status"] = str(task.get("status") or "")
 
14
  import html
15
  import io
16
  import json
17
+ import logging
18
  import os
19
+ import re
20
  import secrets
21
  import time
22
+ from pathlib import Path
23
  from typing import Any, Callable
24
  from urllib.parse import unquote, urlsplit
25
 
26
  import qrcode
27
  import streamlit as st
28
 
29
+ from config import APP_CONFIG
30
  from i18n import tr
31
  from smartpaper_restore_bridge import edge_bridge
32
 
 
392
  return dict(result)
393
 
394
 
395
+ def _completed_output_path(task: dict[str, Any]) -> Path | None:
396
+ user = dict(st.session_state.get("sp_user") or {})
397
+ user_key = re.sub(r"[^A-Za-z0-9_.-]", "_", str(user.get("id") or "").strip())[:100]
398
+ task_key = re.sub(r"[^A-Za-z0-9_.-]", "_", str((task or {}).get("task_id") or "").strip())[:100]
399
+ filename = os.path.basename(str((task or {}).get("output_filename") or "").strip())
400
+ if not user_key or not task_key or not filename or not filename.lower().endswith(".zip"):
401
+ return None
402
+ workspace = Path(str(APP_CONFIG.get("workspace_dir", ".workspace")))
403
+ return workspace / "users" / user_key / "tasks" / task_key / "outputs" / filename
404
+
405
+
406
+ def _restore_completed_artifact_if_needed(task: dict[str, Any]) -> None:
407
+ """Best-effort restore for a completed task when this runtime lost its local ZIP."""
408
+ if str((task or {}).get("status") or "").strip().lower() != "completed":
409
+ return
410
+ output_path = _completed_output_path(task)
411
+ if output_path is None or output_path.is_file():
412
+ return
413
+ user = dict(st.session_state.get("sp_user") or {})
414
+ numeric_user_id = user.get("numeric_id")
415
+ if numeric_user_id in (None, ""):
416
+ logging.warning("[SP_AUTH] bucket_restore_skipped task_id=%s reason=numeric_user_missing", str(task.get("task_id") or ""))
417
+ return
418
+ try:
419
+ from hf_bucket_store import restore_completed_zip
420
+
421
+ restored = restore_completed_zip(
422
+ user_id=numeric_user_id,
423
+ task_id=str(task.get("task_id") or ""),
424
+ output_filename=str(task.get("output_filename") or ""),
425
+ output_path=output_path,
426
+ )
427
+ if restored:
428
+ logging.info("[SP_AUTH] bucket_restore_ok task_id=%s", str(task.get("task_id") or ""))
429
+ except Exception as exc:
430
+ # Recovery storage is never authoritative for task state or quota.
431
+ logging.warning(
432
+ "[SP_AUTH] bucket_restore_nonfatal task_id=%s error=%s",
433
+ str((task or {}).get("task_id") or ""), type(exc).__name__,
434
+ )
435
+
436
+
437
  def recover_generation_task() -> dict[str, Any]:
438
  """Initialize current task state once from the authoritative Worker.
439
 
 
450
  _merge_quota_into_session(result)
451
  task = result.get("task")
452
  if isinstance(task, dict) and task.get("task_id"):
453
+ _restore_completed_artifact_if_needed(task)
454
  st.session_state["sp_generation_task"] = dict(task)
455
  st.session_state["sp_tracked_task_id"] = str(task["task_id"])
456
  st.session_state["sp_last_ui_task_status"] = str(task.get("status") or "")
requirements.txt CHANGED
@@ -12,3 +12,5 @@ PyMuPDF>=1.24,<2
12
  openpyxl>=3.1,<4
13
  tabulate>=0.9,<1
14
  qrcode[pil]>=8,<9
 
 
 
12
  openpyxl>=3.1,<4
13
  tabulate>=0.9,<1
14
  qrcode[pil]>=8,<9
15
+
16
+ huggingface_hub>=1.16,<2
task_executor.py CHANGED
@@ -755,6 +755,7 @@ def main() -> int:
755
  logging.warning("[SP_EXECUTOR] final_heartbeat_error task_id=%s error=%s", args.task_id, _safe(exc, 180))
756
  if state.cancelled():
757
  raise RuntimeError("task_cancel_requested")
 
758
  api.finish(
759
  {
760
  "task_id": args.task_id,
@@ -763,7 +764,7 @@ def main() -> int:
763
  "executor_instance_id": instance_id,
764
  "status": "completed",
765
  "current_node": "package_validated",
766
- "output_filename": str(result.get("output_filename") or "")[:240],
767
  # R14-TQ2-PQ1-R3-R1: completed tasks may carry a nonfatal,
768
  # backend-trusted quality warning. Worker/D1 already persists
769
  # safe_error_code/detail for completed terminal transitions;
@@ -772,6 +773,28 @@ def main() -> int:
772
  "safe_error_detail": str(result.get("safe_error_detail") or "")[:800],
773
  }
774
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
775
  logging.info("[SP_EXECUTOR] completed task_id=%s epoch=%s", args.task_id, epoch)
776
  return 0
777
  except Exception as exc:
 
755
  logging.warning("[SP_EXECUTOR] final_heartbeat_error task_id=%s error=%s", args.task_id, _safe(exc, 180))
756
  if state.cancelled():
757
  raise RuntimeError("task_cancel_requested")
758
+ output_filename = str(result.get("output_filename") or "")[:240]
759
  api.finish(
760
  {
761
  "task_id": args.task_id,
 
764
  "executor_instance_id": instance_id,
765
  "status": "completed",
766
  "current_node": "package_validated",
767
+ "output_filename": output_filename,
768
  # R14-TQ2-PQ1-R3-R1: completed tasks may carry a nonfatal,
769
  # backend-trusted quality warning. Worker/D1 already persists
770
  # safe_error_code/detail for completed terminal transitions;
 
773
  "safe_error_detail": str(result.get("safe_error_detail") or "")[:800],
774
  }
775
  )
776
+ # P0-R2: private HF Bucket storage is a best-effort recovery layer only.
777
+ # The authoritative completed/quota transition above must succeed first;
778
+ # any storage failure below is nonfatal and cannot alter task accounting.
779
+ try:
780
+ from hf_bucket_store import archive_completed_zip
781
+
782
+ archive_result = archive_completed_zip(
783
+ user_id=args.user_id,
784
+ task_id=args.task_id,
785
+ output_filename=output_filename,
786
+ output_path=str(result.get("output_path") or ""),
787
+ )
788
+ if archive_result.get("stored"):
789
+ logging.info(
790
+ "[SP_EXECUTOR] bucket_backup_ok task_id=%s bytes=%s idempotent=%s",
791
+ args.task_id, archive_result.get("size_bytes"), bool(archive_result.get("idempotent")),
792
+ )
793
+ except Exception as exc:
794
+ logging.warning(
795
+ "[SP_EXECUTOR] bucket_backup_nonfatal task_id=%s error=%s",
796
+ args.task_id, type(exc).__name__,
797
+ )
798
  logging.info("[SP_EXECUTOR] completed task_id=%s epoch=%s", args.task_id, epoch)
799
  return 0
800
  except Exception as exc: