API вложений: файл от агента, скачивание, уборка копий (#83)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 14:34:10 +04:00
parent d2e68a9ab1
commit 722393daf9
6 changed files with 278 additions and 12 deletions

View File

@ -16,8 +16,8 @@
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения; `_check_project`: проверки пакета проекта — метка, чужие метки, задачи вне проекта; `_assign_boards`: `board_id` проекта у `ensure_sprint`), выдача (`claim`, `size` у `ensure_attachment` и `boards` у `refresh` — `deliver_params`), отчёты (для `fetch_attachment` в `done` — проверка файла в хранилище и запись `attachment_file`), `request_fetch` (постановка скачивания вложения в одной транзакции, `TooLarge`, `AgentTooOld`; пакет без хвостового `refresh` и без резервной копии), отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `projects`, `attachments`, `db` |
| `backup` | копии файла базы, ротация | `paths` |
| `files` | хранилище файлов вложений `<данные>/files/<sha256>` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` |
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board`; маршруты `/projects` | `queue`, `mirror`, `projects`, `settings`, `backup`, `files`, `relay`, `release` |
| `files` | хранилище файлов вложений `<данные>/files/<sha256>` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ, кроме `allow_empty` для файла от агента), `touch` (mtime при выдаче содержимого), `sweep_downloads` (каталоги копий MCP `downloads/<id>/`, старше 30 дней), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` |
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}`, `POST /agent/files` (от агента, пустой допустим; каталог `Hub.files_dir`, 0700 при старте); вложения `GET /attachments/{id}`, `POST /attachments/{id}/fetch` (`queue.request_fetch`: `TooLarge` → 413, `AgentTooOld` → 409, нет → 404 `no_attachment`), `GET /attachments/{id}/content` (`Content-Disposition: filename*=UTF-8''…`, `nosniff`, `files.touch`); уборка раз в сутки трогает и `Hub.downloads_dir`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board`; маршруты `/projects` | `queue`, `mirror`, `projects`, `settings`, `backup`, `files`, `attachments`, `relay`, `release` |
| `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` |
| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` |
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |

View File

@ -9,7 +9,7 @@
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
- Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed).
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790.
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней), `downloads/` (копии вложений для MCP `<id>/<имя>`, уборка 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790.
- Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное.
## Архитектура

View File

@ -13,9 +13,9 @@ from datetime import timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import NamedTuple
from urllib.parse import parse_qs, unquote, urlparse
from urllib.parse import parse_qs, quote, unquote, urlparse
from . import backup, db, files, history, mirror, projects, queue, relay, release, settings
from . import attachments, backup, db, files, history, mirror, projects, queue, relay, release, settings
from .commands import CommandError
from .paths import backups_dir as default_backups_dir
from .paths import secure_dir
@ -31,6 +31,7 @@ ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # им
NOT_BUILT = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните "
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").encode("utf-8")
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
MIME_RE = re.compile(r"[\w!#$&^.+-]+/[\w!#$&^.+-]+") # тип/подтип без параметров и пробелов: заголовок не внушить
SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений»)
@ -54,6 +55,7 @@ class Hub:
self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir()
self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release"
self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files"
self.downloads_dir = self.db_path.parent / "downloads" # копии вложений для MCP; каталог создаёт MCP
self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15)
# последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version
# неизвестен, а не null (hub-hygiene-d, решение 7)
@ -96,6 +98,12 @@ class Hub:
removed = files.sweep(conn, self.files_dir, now)
if removed:
log.info("уборка файлов: удалено %d", len(removed))
try:
gone = files.sweep_downloads(self.downloads_dir, now)
if gone:
log.info("уборка копий вложений: удалено %d", len(gone))
except Exception: # noqa: BLE001 — копии MCP не должны мешать основной уборке
log.exception("уборка копий вложений: сбой")
return removed
def note_agent_update(self, body):
@ -355,9 +363,9 @@ def h_batch_cancel(hub, conn, m, q, body):
raise ApiError(404, "not_found", "batch")
def h_files_post(hub, conn, m, q, body):
"""Загрузка файла вложения: {content: base64} → {file: sha256, size}. Пробельные символы (переносы строк base64)
отбрасываются до строгой проверки (решение 14)."""
def _put_file(hub, body, allow_empty=False):
"""Общая часть POST /files и POST /agent/files: {content: base64} → {file: sha256, size}. Пробельные символы
(переносы строк base64) отбрасываются до строгой проверки (решение 14)."""
content = body.get("content")
if not isinstance(content, str):
raise ApiError(400, "bad_request", "content — строка base64")
@ -366,7 +374,7 @@ def h_files_post(hub, conn, m, q, body):
except ValueError: # binascii.Error — подкласс ValueError
raise ApiError(400, "bad_request", "content — не base64") from None
try:
sha, size = files.put(hub.files_dir, data)
sha, size = files.put(hub.files_dir, data, allow_empty=allow_empty)
except files.EmptyFile:
raise ApiError(422, "empty_file", "пустой файл") from None
except files.TooLarge:
@ -374,6 +382,55 @@ def h_files_post(hub, conn, m, q, body):
return 200, {"file": sha, "size": size}
def h_files_post(hub, conn, m, q, body):
return _put_file(hub, body)
def h_agent_files_post(hub, conn, m, q, body):
"""Файл от агента (fetch_attachment, решение 5): как POST /files, но пустой файл принимается (решение 13)."""
return _put_file(hub, body, allow_empty=True)
def _attachment(hub, conn, m):
att = attachments.get(conn, hub.files_dir, m.group(1))
if att is None:
raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу")
return att
def h_attachment_get(hub, conn, m, q, body):
return 200, _attachment(hub, conn, m)
def h_attachment_fetch(hub, conn, m, q, body):
author = body.get("author") or "api"
if not isinstance(author, str):
raise ApiError(422, "bad_request", "author — строка")
try:
return 200, queue.request_fetch(conn, hub.files_dir, m.group(1), author)
except KeyError:
raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу") from None
except queue.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (решение 17)
raise ApiError(413, "too_large", str(e)) from None
except queue.AgentTooOld as e:
raise ApiError(409, "agent_too_old", str(e)) from None
except CommandError as e:
raise ApiError(422, "bad_batch", str(e)) from None
def h_attachment_content(hub, conn, m, q, body):
"""Содержимое скачанного вложения; mime — из вложения, негодный → octet-stream; имя — filename* (RFC 5987)."""
att = _attachment(hub, conn, m)
row = conn.execute("SELECT file FROM attachment_file WHERE attachment_id = ?", (m.group(1),)).fetchone()
data = files.read(hub.files_dir, row["file"]) if att["state"] == "ready" and row else None
if data is None:
raise ApiError(404, "not_fetched", "вложение не скачано — запросите скачивание")
files.touch(hub.files_dir, row["file"])
mime = att["mime_type"] if isinstance(att["mime_type"], str) and MIME_RE.fullmatch(att["mime_type"]) else "application/octet-stream"
return 200, Raw(mime, data, {"Content-Disposition": "attachment; filename*=UTF-8''" + quote(att["filename"], safe=""),
"X-Content-Type-Options": "nosniff", "Cache-Control": "no-cache"})
def h_command_retry(hub, conn, m, q, body):
if not isinstance(body.get("author") or "api", str):
raise ApiError(422, "bad_retry", "author — строка")
@ -496,6 +553,7 @@ ROUTES = [
("GET", r"^/agent/known-keys$", h_known_keys, "agent"),
("GET", r"^/agent/release$", h_agent_release, "agent"),
("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"),
("POST", r"^/agent/files$", h_agent_files_post, "agent"),
("GET", r"^/issues$", h_issues, "client"),
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
("GET", r"^/board$", h_board, "client"),
@ -506,6 +564,9 @@ ROUTES = [
("GET", r"^/batches/(\d+)$", h_batch_get, "client"),
("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"),
("POST", r"^/files$", h_files_post, "client"),
("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"),
("POST", r"^/attachments/(\d+)/fetch$", h_attachment_fetch, "client"),
("GET", r"^/attachments/(\d+)/content$", h_attachment_content, "client"),
("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"),
("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"),
("POST", r"^/refresh$", h_refresh, "client"),

View File

@ -4,6 +4,7 @@
import hashlib
import os
import re
import shutil
import tempfile
import time
from pathlib import Path
@ -65,12 +66,12 @@ def _intact(target, sha) -> bool:
return False
def put(root, data: bytes) -> tuple[str, int]:
def put(root, data: bytes, allow_empty: bool = False) -> tuple[str, int]:
"""Сохранить содержимое, вернуть (sha256, размер). Целая копия уже есть (тот же sha256 содержимого) — только обновить
mtime (уборка считает возраст от последней загрузки); оборванная или испорченная — переписать (решение 13). Запись
атомарная: временный файл mkstemp в том же каталоге, fsync, os.replace; при сбое временный файл удаляется
(решение 11)."""
if not data:
(решение 11). allow_empty — пустое содержимое принимается (файл от агента: в Jira бывают пустые вложения, решение 13)."""
if not data and not allow_empty:
raise EmptyFile("пустой файл")
if len(data) > MAX_SIZE:
raise TooLarge(f"файл больше {MAX_SIZE} байт ({len(data)})")
@ -97,6 +98,37 @@ def put(root, data: bytes) -> tuple[str, int]:
return sha, len(data)
def touch(root, sha) -> None:
"""Обновить mtime файла (выдача содержимого продлевает ему жизнь, решение 14); файла нет или sha негоден — ничего."""
if not is_sha(sha):
return
try:
os.utime(path(root, sha))
except FileNotFoundError:
pass
def sweep_downloads(root, now: float | None = None) -> list[str]:
"""Удалить каталоги копий MCP <root>/<attachment_id>/, в которых ничего не менялось KEEP_DAYS (новее из mtime каталога
и его файлов); вернуть имена. Файлы прямо в корне и сбой удаления одного каталога уборку не прерывают."""
root = Path(root)
if not root.is_dir():
return []
cutoff = (time.time() if now is None else now) - KEEP_DAYS * 86400
removed = []
for d in sorted(root.iterdir()):
try:
if d.is_symlink() or not d.is_dir():
continue
newest = max([d.stat().st_mtime] + [f.stat().st_mtime for f in d.iterdir() if f.is_file()])
if newest < cutoff:
shutil.rmtree(d)
removed.append(d.name)
except OSError:
continue
return removed
def sweep(conn, root, now: float | None = None) -> list[str]:
"""Удалить файлы старше KEEP_DAYS (по mtime), на которые не ссылается ни одна ensure_attachment в queued или
claimed; вернуть имена удалённых. Правило одно для всех обычных файлов каталога: брошенные временные .*.tmp уходят

View File

@ -1030,3 +1030,142 @@ def test_issue_has_attachments(hub):
got = c.get("/issues/A-1")
assert got["attachments"][0]["attachment_id"] == "7" and got["attachments"][0]["state"] == "none"
assert got["description_attachment_ids"] == ["7"]
# --- вложения из Jira по запросу (#83) ----------------------------------------------
ATT_NAME = "скрин 1.png"
def _seed_attachment(hub, mime="image/png", size=3, name=ATT_NAME):
conn = hub["hub"].conn()
try:
from jira_hub import mirror
mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [
{"id": "31337", "filename": name, "size": size, "mime_type": mime, "created": "2026-10-20T10:00:00.000+0300"}]}], [])
finally:
conn.close()
def _content(hub, path):
req = urllib.request.Request(hub["base"] + path, headers={"Authorization": "Bearer " + hub["client"].token})
return urllib.request.urlopen(req)
def _fetch_done(hub, data=b"abc"):
"""Запрос скачивания, claim агентом, загрузка файла и отчёт done."""
out = hub["client"].post("/attachments/31337/fetch", {"author": "web"})
cmd = hub["agent"].post("/agent/claim", {})["command"]
up = hub["agent"].post("/agent/files", {"content": _b64(data)})
hub["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": up})
return out, cmd, up
def test_agent_files_post(hub):
a = hub["agent"]
out = a.post("/agent/files", {"content": _b64(b"abc")})
assert out == {"file": hashlib.sha256(b"abc").hexdigest(), "size": 3}
with pytest.raises(HubError) as e:
hub["client"].post("/agent/files", {"content": _b64(b"abc")})
assert e.value.status == 401
assert a.post("/agent/files", {"content": ""}) == {"file": hashlib.sha256(b"").hexdigest(), "size": 0}
with pytest.raises(HubError) as e:
a.post("/agent/files", {"content": _b64(b"\0" * (11 * 1024 * 1024))})
assert e.value.status == 413 and e.value.error == "too_large"
with pytest.raises(HubError) as e:
a.post("/agent/files", {"content": "***"})
assert e.value.status == 400
def test_attachment_get_404(hub):
for method, path in (("get", "/attachments/1"), ("post", "/attachments/1/fetch"), ("get", "/attachments/1/content")):
with pytest.raises(HubError) as e:
getattr(hub["client"], method)(path)
assert e.value.status == 404 and e.value.error == "no_attachment"
def test_attachment_fetch_flow(hub):
_seed_attachment(hub)
out, cmd, up = _fetch_done(hub)
assert out["state"] == "fetching" and out["command_id"] and out["batch_id"]
assert cmd["type"] == "fetch_attachment" and cmd["pause_seconds"] == 2
got = hub["client"].get("/attachments/31337")
assert got["state"] == "ready" and got["filename"] == ATT_NAME and got["key"] == "A-1"
resp = _content(hub, "/attachments/31337/content")
assert resp.status == 200 and resp.read() == b"abc"
assert resp.headers["Content-Type"] == "image/png"
assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png"
assert resp.headers["X-Content-Type-Options"] == "nosniff"
assert resp.headers["Content-Length"] == "3"
def test_attachment_content_updates_mtime(hub):
_seed_attachment(hub)
_, _, up = _fetch_done(hub)
p = hub["hub"].files_dir / up["file"]
old = time.time() - 10 * 86400
os.utime(p, (old, old))
_content(hub, "/attachments/31337/content").read()
assert p.stat().st_mtime > time.time() - 60
def test_attachment_content_special_name(hub):
name = "a:b/c d\u00e9.txt"
_seed_attachment(hub, name=name, mime="text/plain")
_fetch_done(hub)
resp = _content(hub, "/attachments/31337/content")
assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''a%3Ab%2Fc%20d%C3%A9.txt"
def test_attachment_content_not_fetched(hub):
_seed_attachment(hub)
with pytest.raises(HubError) as e:
hub["client"].get("/attachments/31337/content")
assert e.value.status == 404 and e.value.error == "not_fetched"
def test_attachment_content_bad_mime(hub):
_seed_attachment(hub, mime="weird")
_fetch_done(hub)
assert _content(hub, "/attachments/31337/content").headers["Content-Type"] == "application/octet-stream"
def test_attachment_fetch_twice_same_command(hub):
_seed_attachment(hub)
a = hub["client"].post("/attachments/31337/fetch", {})
b = hub["client"].post("/attachments/31337/fetch", {})
assert a["command_id"] == b["command_id"] and b["state"] == "fetching"
def test_attachment_fetch_too_large_413(hub):
_seed_attachment(hub, size=25_000_000)
with pytest.raises(HubError) as e:
hub["client"].post("/attachments/31337/fetch", {})
assert e.value.status == 413 and e.value.error == "too_large"
assert e.value.detail == "файл больше 10 МБ (23,8 МБ) — скачайте в Jira"
assert hub["client"].get("/batches")["batches"] == []
def test_attachment_fetch_agent_too_old_409(hub):
_seed_attachment(hub)
hub["agent"].post("/agent/claim", {"agent_version": 13})
with pytest.raises(HubError) as e:
hub["client"].post("/attachments/31337/fetch", {})
assert e.value.status == 409 and e.value.error == "agent_too_old"
assert e.value.detail == "агент v13 не умеет скачивать вложения — нужен v14"
def test_sweep_files_downloads(hub):
h = hub["hub"]
d = h.downloads_dir / "31337"
d.mkdir(parents=True)
(d / "a.png").write_bytes(b"x")
old = time.time() - 31 * 86400
os.utime(d, (old, old))
os.utime(d / "a.png", (old, old))
conn = h.conn()
try:
h.sweep_files(conn)
finally:
conn.close()
assert not d.exists()

View File

@ -155,3 +155,37 @@ def test_put_rewrites_corrupted_copy_of_same_size(root):
(root / ABC).write_bytes(b"xyz") # тот же размер, другое содержимое
assert files.put(root, b"abc") == (ABC, 3)
assert (root / ABC).read_bytes() == b"abc"
# --- allow_empty, touch, sweep_downloads (#83) ---------------------------------
def test_put_allow_empty(root):
assert files.put(root, b"", allow_empty=True) == (hashlib.sha256(b"").hexdigest(), 0)
with pytest.raises(files.EmptyFile):
files.put(root, b"")
def test_touch_updates_mtime(root):
sha, _ = files.put(root, b"abc")
old = time.time() - 5 * DAY
os.utime(root / sha, (old, old))
files.touch(root, sha)
assert (root / sha).stat().st_mtime > time.time() - 60
files.touch(root, "0" * 64) # файла нет — не ошибка
files.touch(root, "../x") # негодный sha — не ошибка
def test_sweep_downloads(tmp_path):
root = tmp_path / "downloads"
old, fresh = root / "31337", root / "31338"
for d in (old, fresh):
d.mkdir(parents=True)
(d / "a.png").write_bytes(b"x")
(root / "loose.txt").write_text("x")
t = time.time() - 31 * DAY
os.utime(old / "a.png", (t, t))
os.utime(old, (t, t))
os.utime(root / "loose.txt", (t, t))
assert files.sweep_downloads(root) == ["31337"]
assert not old.exists() and fresh.exists() and (root / "loose.txt").exists()
assert files.sweep_downloads(tmp_path / "нет") == []