feat(commands): команда ensure_attachment — проверка файла при постановке, size при выдаче (#43)

SPEC ensure_attachment {key, file, filename}, пауза 30 с, по состоянию не подтверждается; queue._check_setup: формат file, файл загружен («файл <sha> не загружен: POST /files»), filename без / и \, без кавычек и управляющих символов (R-2); claim, довыдача и GET /agent/commands/{id} дописывают size (null без файла), в базе params без size, retry проходит (решения 17–18).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-16 01:46:14 +04:00
parent e8707d4762
commit 9b21060d60
12 changed files with 215 additions and 25 deletions

View File

@ -11,7 +11,7 @@
| `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` |
| `mirror` | зеркало задач, связей и комментариев, `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board` | `db` |
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` |
| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения), выдача (`claim`, `size` у `ensure_attachment` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `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` | `queue`, `mirror`, `settings`, `backup`, `files`, `relay`, `release` |
@ -34,6 +34,7 @@
## Потоки данных
- Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`.
- Вложение: файл → `POST /files` → `files/<sha256>` → `ensure_attachment {key, file, filename}` в пакете (`queue._check_setup` проверяет, что файл есть) → `claim` (хаб дописывает `size`, в базе `params` без него) → агент `GET /agent/files/{sha256}` → Jira; уборка `files/` — из `tick` раз в сутки.
- Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`.
- Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом.
- Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`.

View File

@ -194,6 +194,7 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
| `ensure_status` | `key`, `target` (имя статуса или список-путь) |
| `ensure_link` | `source`, `target`, `link_type`, `replace` |
| `ensure_sprint` | `key`, `sprint` (имя или `backlog`) |
| `ensure_attachment` | `key` (ключ или `new:<n>`), `file` (sha256 из `POST /files`), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче агенту |
| `refresh` | `keys` или `jql` |
Тип `bug` — «Ошибка» в Jira, обязательные поля те же, что у задачи. Ставится, только если в настройках есть
@ -213,7 +214,11 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
```
Ответ: `{id, estimate_seconds}`. Зазоры между записями в Jira по умолчанию: `ensure_issue` и `ensure_fields` 120 с,
`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint` 30 с, `refresh` 2 с (настройка `pacing`).
`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment` 30 с, `refresh` 2 с (настройка `pacing`).
`ensure_attachment` ставится, только если файл уже загружен (`POST /files`), иначе 422 «команда N: файл <sha> не
загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба
(`GET /agent/files/{sha256}`) и прикладывает. По зеркалу команда не подтверждается (вложений в зеркале нет).
## Пример сценария

View File

@ -122,3 +122,11 @@ filename="<имя UTF-8>"; filename*=UTF-8''<percent-encoded>`, `Content-Type: a
перезапусках хаба в сутки уборок больше одной (спека уточнена: «в пределах работы процесса», рулинг R-1).
16. 15.10.2026 — `GET /agent/files/{id}` с id не из 64 hex нижнего регистра — 404 `no_file`, а не общий `not_found`.
Обоснование: для агента это тот же случай «файла нет». Цена: нет.
17. 15.10.2026 — тексты 422 без формулировки в спеке: «команда N: file — sha256 из POST /files (64 символа 0-9a-f)»,
«команда N: filename пустой» (только пробелы; пустая строка — общее «нет filename; ожидаются …»), «команда N:
filename без / и \ — имя вложения, а не путь». Порядок проверок: формат `file`, наличие файла, `filename`.
Цена: формулировки правятся одной строкой.
18. 15.10.2026 — `queue.create_batch`, `queue.retry_command` и `queue.claim` получают `files_dir` параметром (по
умолчанию `None`: вложение не ставится — «файл не загружен», при выдаче `size = null`); `api` передаёт
`Hub.files_dir`; `GET /agent/commands/{id}` дописывает `size` через `queue.deliver_params`. Обоснование: `queue`
остаётся чистой логикой над соединением (решение 10). Цена: новый вызов без `files_dir` молча отвергает вложения.

View File

@ -135,7 +135,8 @@ SHALL перевести её в `confirmed_by_state`: `ensure_fields` — вс
При постановке пакета хаб SHALL отвергать 422 без создания пакета: `ensure_issue` с `issue_type = bug`, если в настройках
нет `issue_types.bug` с непустым `name` или `id` («нет настройки issue_types.bug»); `ensure_attachment`, если `file` — не
64 hex-символа в нижнем регистре, файла с таким sha256 нет в хранилище хаба («файл <sha> не загружен: POST /files»),
`filename` пустой или содержит `/` или `\`. Клиент `size` не передаёт (лишний параметр — 422). При выдаче агенту
`filename` пустой (или из одних пробелов) или содержит `/`, `\`, `"` или управляющие символы (код < 32 и 127,
в том числе CR и LF): такие имена ломают `multipart/form-data`. Клиент `size` не передаёт (лишний параметр — 422). При выдаче агенту
(`claim`, в том числе повторной) хаб SHALL добавить к параметрам `ensure_attachment` `size` — текущий размер файла в
хранилище, или `size = null`, если файла уже нет; в базе параметры команды остаются без `size`, поэтому повтор команды
(`POST /commands/{id}/retry`) проходит проверку как новая.

View File

@ -144,7 +144,7 @@ def h_claim(hub, conn, m, q, body):
deadline = time.monotonic() + hub.claim_wait
while True:
cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"),
backup=hub.make_backup)
backup=hub.make_backup, files_dir=hub.files_dir)
if cmd or time.monotonic() >= deadline:
break
time.sleep(1)
@ -178,7 +178,9 @@ def h_agent_settings(hub, conn, m, q, body):
def h_agent_command(hub, conn, m, q, body):
return 200, _not_found(queue.get_command(conn, _int(m)), "command")
"""Команда по id для восстановления из журнала агента: параметры — как при выдаче (с size у ensure_attachment)."""
cmd = _not_found(queue.get_command(conn, _int(m)), "command")
return 200, {**cmd, "params": queue.deliver_params(cmd, hub.files_dir)}
def h_agent_result(hub, conn, m, q, body):
@ -270,7 +272,7 @@ def h_batches_post(hub, conn, m, q, body):
if not isinstance(body.get("commands"), list):
raise ApiError(422, "bad_batch", "commands — список команд")
try:
return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"))
return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"), files_dir=hub.files_dir)
except CommandError as e:
raise ApiError(422, "bad_batch", str(e))
@ -313,7 +315,7 @@ def h_command_retry(hub, conn, m, q, body):
if not isinstance(body.get("author") or "api", str):
raise ApiError(422, "bad_retry", "author — строка")
try:
return 201, queue.retry_command(conn, _int(m), body.get("author") or "api")
return 201, queue.retry_command(conn, _int(m), body.get("author") or "api", files_dir=hub.files_dir)
except KeyError:
raise ApiError(404, "not_found", "command")
except CommandError as e:

View File

@ -31,6 +31,9 @@ SPEC = {
"hints": {"link_type": "логическое имя из link_types", "replace": "true|false"}},
"ensure_sprint": {"required": ["key", "sprint"], "optional": [], "keys": ["key"],
"hints": {"sprint": "имя спринта или backlog"}},
"ensure_attachment": {"required": ["key", "file", "filename"], "optional": [], "keys": ["key"],
"hints": {"file": "sha256 из POST /files; в MCP — path к локальному файлу",
"filename": "имя вложения"}},
"refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"], "hints": {"keys": "список"},
"note": "keys или jql"},
}
@ -182,11 +185,12 @@ def _norm_text(value):
def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]:
"""Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment и refresh по состоянию не подтверждаются:
потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability,
решение 4); совпадение темы с задачей неизвестного автора не доказательство."""
"""Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment, ensure_attachment и refresh по состоянию не
подтверждаются: потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля
(bridge-reliability, решение 4); совпадение темы с задачей неизвестного автора не доказательство; вложений в
зеркале нет (bug-and-attachments)."""
ctype, p = cmd["type"], cmd["params"]
if ctype in ("ensure_issue", "ensure_comment", "refresh"):
if ctype in ("ensure_issue", "ensure_comment", "ensure_attachment", "refresh"):
return False, None
issue = mirror.get_issue(conn, p.get("key") or p.get("source"))
if issue is None:

View File

@ -7,7 +7,7 @@ from datetime import timedelta
from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim)
from . import commands as C
from . import db, mirror, settings
from . import db, files, mirror, settings
log = logging.getLogger(__name__)
@ -45,19 +45,33 @@ def _dump(value) -> str:
# --- пакеты -----------------------------------------------------------------
def _check_setup(conn, cmds) -> None:
"""Проверки постановки, которым нужны настройки хаба (bug-and-attachments, решения 6–7): 422 до создания пакета.
Номер команды — как в присланном пакете (хвостовой refresh ещё не добавлен)."""
def _check_setup(conn, cmds, files_dir) -> None:
"""Проверки постановки, которым нужны настройки хаба и хранилище файлов (bug-and-attachments, решения 6–7): 422
до создания пакета. Номер команды — как в присланном пакете (хвостовой refresh ещё не добавлен).
files_dir=None — хранилища нет: любой файл «не загружен»."""
bug = (settings.get(conn, "issue_types") or {}).get("bug")
bug_ok = isinstance(bug, dict) and bool(bug.get("name") or bug.get("id"))
for i, c in enumerate(cmds, 1):
if c["type"] == "ensure_issue" and c["params"]["issue_type"] == "bug" and not bug_ok:
p = c["params"]
if c["type"] == "ensure_issue" and p["issue_type"] == "bug" and not bug_ok:
raise C.CommandError(f"команда {i}: нет настройки issue_types.bug")
if c["type"] == "ensure_attachment":
if not files.is_sha(p["file"]):
raise C.CommandError(f"команда {i}: file — sha256 из POST /files (64 символа 0-9a-f)")
if files_dir is None or not files.exists(files_dir, p["file"]):
raise C.CommandError(f"команда {i}: файл {p['file']} не загружен: POST /files")
if not p["filename"].strip():
raise C.CommandError(f"команда {i}: filename пустой")
if "/" in p["filename"] or "\\" in p["filename"]:
raise C.CommandError(f"команда {i}: filename без / и \\ — имя вложения, а не путь")
if '"' in p["filename"] or any(ord(ch) < 32 or ord(ch) == 127 for ch in p["filename"]):
# кавычка, CR, LF ломают заголовок части multipart/form-data у агента (R-2)
raise C.CommandError(f"команда {i}: filename без кавычек и управляющих символов (CR, LF и др.)")
def create_batch(conn, title, author, cmds) -> dict:
def create_batch(conn, title, author, cmds, files_dir=None) -> dict:
cmds = C.validate(cmds)
_check_setup(conn, cmds)
_check_setup(conn, cmds, files_dir)
keys = []
for c in cmds:
keys += [k for k in C.referenced_keys(c) if k not in keys]
@ -172,7 +186,16 @@ def _redeliver(conn, now) -> dict | None:
log.warning("команда %s: агент не отчитался после %s выдач — failed", cmd["id"], MAX_DELIVERIES)
def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None) -> dict | None:
def deliver_params(cmd, files_dir) -> dict:
"""Параметры команды для агента: ensure_attachment получает size — текущий размер файла в хранилище или None,
если файла уже нет (решение 5). В базе params остаются без size: повтор команды проходит проверку как новая."""
if cmd["type"] != "ensure_attachment":
return cmd["params"]
size = files.size(files_dir, cmd["params"].get("file")) if files_dir is not None else None
return {**cmd["params"], "size": size}
def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None, files_dir=None) -> dict | None:
now = db.now()
to_backup = None
with db.transaction(conn):
@ -214,7 +237,8 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non
backup(to_backup)
except Exception: # сбой копии не должен держать очередь: команда уже взята
log.warning("резервная копия %s не создана, команда %s выдаётся без неё", to_backup, cmd["id"], exc_info=True)
return {"id": cmd["id"], "type": cmd["type"], "params": cmd["params"], "pause_seconds": pause, "batch_id": cmd["batch_id"]}
return {"id": cmd["id"], "type": cmd["type"], "params": deliver_params(cmd, files_dir), "pause_seconds": pause,
"batch_id": cmd["batch_id"]}
# --- отчёты -----------------------------------------------------------------
@ -325,11 +349,12 @@ def cancel_command(conn, command_id, force=False) -> dict:
return get_command(conn, command_id)
def retry_command(conn, command_id, author) -> dict:
def retry_command(conn, command_id, author, files_dir=None) -> dict:
cmd = get_command(conn, command_id)
if cmd is None:
raise KeyError(command_id)
return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": cmd["params"]}])
return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": cmd["params"]}],
files_dir=files_dir)
# --- агент и автообновление ----------------------------------------------------

View File

@ -5,7 +5,7 @@ import os
import secrets
DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30,
"ensure_link": 30, "ensure_sprint": 30, "refresh": 2}
"ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "refresh": 2}
DEFAULTS = {
"base_jql": "project = AURORA AND labels = AWPCA",
@ -132,7 +132,7 @@ def pacing_from_config(p: dict) -> dict:
"""Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд."""
issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30)
return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment,
"ensure_link": comment, "ensure_sprint": comment, "refresh": 2}
"ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "refresh": 2}
def _issue_types(cfg: dict) -> dict:

View File

@ -708,3 +708,22 @@ def test_tick_sweep_failure_logged_auto_refresh_still_runs(hub, monkeypatch, cap
hub["hub"].tick() # не бросает
assert "уборка файлов: сбой" in caplog.text
assert [b["title"] for b in hub["client"].get("/batches")["batches"]] == ["Автообновление"]
def test_attachment_flow_over_api(hub):
"""POST /batches проверяет файл; claim и GET /agent/commands/{id} отдают size; retry — params без size."""
c, a = hub["client"], hub["agent"]
sha = c.post("/files", {"content": _b64(b"abc")})["file"]
with pytest.raises(HubError) as e:
c.post("/batches", {"title": "x", "commands": [{"type": "ensure_attachment", "params": {
"key": "A-1", "file": "0" * 64, "filename": "a.xlsx"}}]})
assert e.value.status == 422 and e.value.detail == f"команда 1: файл {'0' * 64} не загружен: POST /files"
c.post("/batches", {"title": "x", "commands": [{"type": "ensure_attachment", "params": {
"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"}}]})
cmd = a.post("/agent/claim", {})["command"]
assert cmd["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx", "size": 3}
assert cmd["pause_seconds"] == 30
assert a.get(f"/agent/commands/{cmd['id']}")["params"]["size"] == 3
a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 500"})
retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"})
assert c.get(f"/batches/{retry['id']}")["commands"][0]["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"}

View File

@ -64,6 +64,27 @@ def test_validate_accepts_bug_issue_type():
assert commands.SPEC["ensure_issue"]["hints"]["issue_type"] == "task|epic|bug"
ABC = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" # sha256("abc")
def test_ensure_attachment_contract():
ok = commands.validate([
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "Ошибка"}},
{"type": "ensure_attachment", "params": {"key": "new:1", "file": ABC, "filename": "ОСВ 60.xlsx"}}])
assert commands.referenced_keys(ok[1]) == ["new:1"] and commands.temp_keys_used(ok[1]) == ["new:1"]
assert "ensure_attachment" in commands.WRITE_TYPES
assert ("- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), "
"filename (имя вложения)") in commands.describe().splitlines()
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": ABC}}])
assert str(e.value) == "команда 1 (ensure_attachment): нет filename; ожидаются key, file, filename"
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": ABC, "filename": "a", "size": 3}}])
assert str(e.value) == "команда 1 (ensure_attachment): лишние параметры size; ожидаются key, file, filename"
with pytest.raises(CommandError, match="file — строка"):
commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": 5, "filename": "a"}}])
def test_spec_hints_name_known_params():
for ctype, spec in commands.SPEC.items():
assert set(spec.get("hints", {})) <= set(spec["required"]) | set(spec["optional"]), ctype
@ -109,6 +130,7 @@ LT = {"связанные": "ASUPR_Связанные", "блокирует на
({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}, True),
({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "backlog"}}, False),
({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": "b"}}, False),
({"type": "ensure_attachment", "params": {"key": "A-1", "file": "a" * 64, "filename": "a.xlsx"}}, False),
({"type": "refresh", "params": {"keys": ["A-1"]}}, False),
({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False),
])

View File

@ -1,6 +1,8 @@
import hashlib
import pytest
from jira_hub import db, mirror, queue, settings
from jira_hub import db, files, mirror, queue, settings
from jira_hub.commands import CommandError
from jira_hub.queue import ClaimedError
@ -522,6 +524,21 @@ def bug(temp="new:1", summary="ОСВ 60 не распознана"):
"labels": ["AWPCA"]}}
SHA = hashlib.sha256(b"abc").hexdigest()
@pytest.fixture
def store(tmp_path):
"""Каталог хранилища с файлом «abc»."""
root = tmp_path / "files"
files.put(root, b"abc")
return root
def attach(key="A-1", file=SHA, filename="ОСВ 60.xlsx"):
return {"type": "ensure_attachment", "params": {"key": key, "file": file, "filename": filename}}
def test_bug_without_setting_rejected(conn):
"""Спека commands «Ошибка без настройки типа»: 422 с номером команды, пакет не создан."""
settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}})
@ -543,3 +560,82 @@ def test_bug_with_setting_accepted(conn, value):
settings.set_(conn, "issue_types", {"bug": value})
b = queue.create_batch(conn, "ошибка", "claude", [bug()])
assert b["count"] == 2 # ensure_issue + хвостовой refresh
def test_attachment_without_file_rejected(conn, store):
"""Спека commands «Вложение без загруженного файла»: 422, пакет не создан."""
missing = "0" * 64
with pytest.raises(CommandError) as e:
queue.create_batch(conn, "вложение", "claude", [attach(file=missing)], files_dir=store)
assert str(e.value) == f"команда 1: файл {missing} не загружен: POST /files"
with pytest.raises(CommandError, match=f"файл {SHA} не загружен"):
queue.create_batch(conn, "вложение", "claude", [attach()]) # хранилище не передано
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
@pytest.mark.parametrize("params, msg", [
({"file": SHA.upper()}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"),
({"file": SHA[:63]}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"),
({"filename": " "}, "команда 1: filename пустой"),
({"filename": "docs/ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"),
({"filename": "C:\\ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"),
({"filename": 'ОСВ "60".xlsx'}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),
({"filename": "ОСВ\n60.xlsx"}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),
({"filename": "ОСВ\r.xlsx"}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),
({"filename": "ОСВ\x7f.xlsx"}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),
])
def test_attachment_params_rejected(conn, store, params, msg):
cmd = attach()
cmd["params"].update(params)
with pytest.raises(CommandError) as e:
queue.create_batch(conn, "вложение", "claude", [cmd], files_dir=store)
assert str(e.value) == msg
def test_bug_with_attachment_one_batch(conn, store):
"""Спека commands «Ошибка с вложением одним пакетом»: вложение выдаётся после ensure_issue с ключом и size."""
settings.set_(conn, "issue_types", {"bug": {"name": "Ошибка", "id": None}})
b = queue.create_batch(conn, "ошибка", "claude", [
bug(), {"type": "ensure_sprint", "params": {"key": "new:1", "sprint": "Спринт 2"}}, attach(key="new:1")],
files_dir=store)
assert b["count"] == 4 and b["estimate_seconds"] == 120 + 30 + 30 + 2
c1 = queue.claim(conn, files_dir=store)
assert c1["type"] == "ensure_issue"
queue.report_result(conn, c1["id"], "done", result={"key": "AURORA-1650"})
c2 = queue.claim(conn, files_dir=store)
assert c2["type"] == "ensure_sprint"
queue.report_result(conn, c2["id"], "done")
c3 = queue.claim(conn, files_dir=store)
assert c3["type"] == "ensure_attachment" and c3["pause_seconds"] == 30
assert c3["params"] == {"key": "AURORA-1650", "file": SHA, "filename": "ОСВ 60.xlsx", "size": 3}
again = queue.claim(conn, files_dir=store) # довыдача — тоже с size
assert again["id"] == c3["id"] and again["params"]["size"] == 3
stored = queue.get_command(conn, c3["id"])["params"]
assert stored == {"key": "AURORA-1650", "file": SHA, "filename": "ОСВ 60.xlsx"} # в базе без size
def test_attachment_size_null_when_file_gone(conn, store):
queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store)
(store / SHA).unlink()
c = queue.claim(conn, files_dir=store)
assert c["type"] == "ensure_attachment" and c["params"]["size"] is None
assert queue.claim(conn)["params"]["size"] is None # без хранилища — тоже null, не исключение
assert queue.deliver_params(queue.get_command(conn, c["id"]), store)["size"] is None
def test_attachment_retry_passes_validation(conn, store):
queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store)
c = queue.claim(conn, files_dir=store)
queue.report_result(conn, c["id"], "failed", error="HTTP 413")
r = queue.retry_command(conn, c["id"], "claude", files_dir=store)
assert queue.batch_view(conn, r["id"])["commands"][0]["params"] == {"key": "A-1", "file": SHA, "filename": "ОСВ 60.xlsx"}
(store / SHA).unlink() # убран уборкой
with pytest.raises(CommandError, match=f"команда 1: файл {SHA} не загружен: POST /files"):
queue.retry_command(conn, c["id"], "claude", files_dir=store)
def test_attachment_not_confirmed_by_state(conn, store):
mirror.apply_state(conn, [{"key": "A-1", "summary": "Т", "labels": []}], [])
queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store)
c = queue.claim(conn, files_dir=store)
assert queue.reconcile(conn) == [] and queue.get_command(conn, c["id"])["status"] == "claimed"

View File

@ -87,3 +87,10 @@ def test_init_bug_from_config(conn, extra, expected):
types = settings.get(conn, "issue_types")
assert types["bug"] == expected
assert types["task"] == {"name": "Задача", "id": "10100"} and types["epic"] == {"name": "Фича", "id": "12300"}
def test_attachment_pacing(conn):
assert settings.DEFAULT_PACING["ensure_attachment"] == 30
assert settings.pacing(conn)["ensure_attachment"] == 30
assert settings.pacing_from_config({"issue_seconds": 90, "comment_seconds": 20})["ensure_attachment"] == 20
settings.validate("pacing", {"ensure_attachment": 10})