plan: hotfolder-direct-write (#26)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-09 16:28:31 +04:00
parent 85f4e9c847
commit 7ec3dd49fd

View File

@ -0,0 +1,359 @@
# Запись в горячую папку прямо в Input — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (план из 3 задач, ≤5 — Native по
> `~/.claude/CLAUDE.md`). Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `put` пишет `<job_id>.<ext>` сразу в `Input` (`wb`, перезапись), без `Tmp/.part` и rename; `SCAN_TMP_DIR`
уходит из шлюза; `SCAN_TIMEOUT_SECONDS` по умолчанию 1200.
**Architecture:** Меняются только `SmbHotFolder.put/cleanup` и `LocalHotFolder.put/cleanup`; протокол `HotFolder`,
воркер и эмулятор не меняются. `LocalHotFolder.tmp_dir` остаётся для эмулятора.
**Tech Stack:** Python 3.11, smbprotocol (`smbclient`), pytest, ruff, mypy.
**Spec:** `openspec/changes/hotfolder-direct-write/` (proposal, design D-1…D-5, R-1…R-4, specs/image-recognition-gateway)
Рабочий каталог: `/Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway` (ветка
`feat/26-hotfolder-direct-write`). Git — `git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway`.
`pytest` — из `api/` (`.venv/bin/pytest`), `make` — из корня репозитория.
## Global Constraints
- Запись: `smbclient.open_file(<Input>\<имя>, mode="wb", **credentials)` / `open(<input>/<имя>, "wb")`; существующий файл перезаписывается.
- Временных файлов, `Tmp` и rename у шлюза нет; `Input` создаётся терпимо (`_ensure_dir` / `mkdir(exist_ok=True)`), корень — нет.
- Ошибка записи: недописанный `<Input>/<имя>` снимается только при `cleanup=True`; ошибка снятия подавляется, поднимается исходная.
- При `SCAN_CLEANUP=false` ничего не удаляется; `cleanup` удаляет `Output/<job_id>/`, `Input/<имя>`, `Input/<имя>.done` — и больше ничего.
- `SCAN_TIMEOUT_SECONDS: PositiveFloat = 1200`; `SCAN_TMP_DIR` в `Settings` нет (у эмулятора — есть).
- Учётные данные передаются в каждый вызов `smbclient` (как сейчас).
## Review Focus
- Повторный `put` того же имени с другим содержимым → файл перезаписан новым содержимым (оба режима).
- Ошибка посреди `write` при `cleanup=False` → обрезанный файл остаётся, исключение исходное.
- Ошибка посреди `write` при `cleanup=True` и падающем `remove` → поднимается исходная ошибка записи.
- Старый `.env` с `SCAN_TMP_DIR=Tmp` → шлюз стартует (`extra="ignore"`).
- `put` не создаёт `Tmp` ни в одном режиме; эмулятор по-прежнему собирает ответ в `Tmp`.
---
### Task 1: `SmbHotFolder` — прямая запись
**Files:**
- Modify: `api/src/gateway/smb.py` (докстринг модуля, `__init__`, `put`, `_drop_part` → `_drop_partial`, `cleanup`)
- Test: `api/tests/unit/test_smb_hotfolder.py` (`TMP`, `TestPaths`, `TestPut`, `TestPutWithoutReplace` → `TestPutOverwrite`, `TestCleanup*`)
**Interfaces:**
- Produces: `SmbHotFolder(host, path, input_dir="Input", output_dir="Output", *, login="", password="", settle_seconds=2, clock=..., cleanup=False)` — **без** `tmp_dir` и атрибута `tmp_dir`.
- [ ] **Step 1: Переписать тесты `put` и `cleanup`**
Удалить константу `TMP`, из `TestPaths.test_default_layout` — `assert hf.tmp_dir == TMP`, из `test_custom_dir_names` —
`tmp_dir=...` и его assert. `TestPut` и `TestPutWithoutReplace` заменить на:
```python
class TestPut:
def test_writes_directly_into_input(self, fake: FakeSmbClient, share_root: Path) -> None:
name = inbox_name(_job_id(), "aep_5.PDF")
data = bytes(range(256)) * 10
_hotfolder().put(name, data)
assert fake.calls == [("mkdir", (INPUT,), {}), ("open_file", (f"{INPUT}\\{name}", "wb"), {})]
assert (share_root / "Input" / name).read_bytes() == data
assert sorted(p.name for p in share_root.iterdir()) == ["Input"] # Tmp не создаётся
def test_existing_input_dir_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
(share_root / "Input").mkdir()
_hotfolder().put("a.pdf", b"1")
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
def test_missing_root_raises_and_writes_nothing(self, fake: FakeSmbClient) -> None:
with pytest.raises(OSError) as excinfo:
_hotfolder().put("a.pdf", b"1")
assert isinstance(excinfo.value, SMBOSError)
assert fake.funcs() == ["mkdir"]
assert not fake.base.exists()
def test_mkdir_denied_but_input_exists_is_fine(self, fake: FakeSmbClient, share_root: Path) -> None:
"""Урезанные ACL: нельзя создавать подкаталоги в корне, но `Input` уже есть и в него писать можно."""
(share_root / "Input").mkdir()
fake.fail["mkdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
_hotfolder().put("a.pdf", b"1")
assert (share_root / "Input" / "a.pdf").read_bytes() == b"1"
assert fake.funcs() == ["mkdir", "isdir", "open_file"]
def test_mkdir_denied_and_no_input_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
fake.fail["mkdir"] = denied
with pytest.raises(SMBOSError) as excinfo:
_hotfolder().put("a.pdf", b"1")
assert excinfo.value is denied
assert fake.funcs() == ["mkdir", "isdir"]
def test_mkdir_denied_and_isdir_fails_raises_original(self, fake: FakeSmbClient, share_root: Path) -> None:
denied = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
fake.fail["mkdir"] = denied
fake.fail["isdir"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
with pytest.raises(SMBOSError) as excinfo:
_hotfolder().put("a.pdf", b"1")
assert excinfo.value is denied
assert fake.funcs() == ["mkdir", "isdir"]
def test_empty_data_is_written(self, fake: FakeSmbClient, share_root: Path) -> None:
_hotfolder().put("empty.pdf", b"")
assert (share_root / "Input" / "empty.pdf").read_bytes() == b""
def test_spaces_and_cyrillic_are_accepted(self, fake: FakeSmbClient, share_root: Path) -> None:
name = inbox_name(_job_id(), "Отчёт за 2024 год (копия).pdf")
_hotfolder().put(name, b"x")
assert (share_root / "Input" / name).read_bytes() == b"x"
# test_rejects_invalid_names_before_any_call — без изменений
def test_write_failure_keeps_partial_file_when_cleanup_disabled(
self, fake: FakeSmbClient, share_root: Path
) -> None:
fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
with pytest.raises(SMBOSError):
_hotfolder().put("a.pdf", b"data")
assert "remove" not in fake.funcs()
def test_write_failure_removes_partial_file_when_cleanup_enabled(
self, fake: FakeSmbClient, share_root: Path
) -> None:
fake.fail["open_file"] = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
with pytest.raises(SMBOSError):
_hotfolder(cleanup=True).put("a.pdf", b"data")
assert fake.calls[-1] == ("remove", (f"{INPUT}\\a.pdf",), {})
def test_cleanup_error_does_not_mask_original(self, fake: FakeSmbClient, share_root: Path) -> None:
original = SMBOSError(NtStatus.STATUS_ACCESS_DENIED, INPUT)
fake.fail["open_file"] = original
fake.fail["remove"] = SMBException("соединение потеряно")
with pytest.raises(SMBOSError) as excinfo:
_hotfolder(cleanup=True).put("a.pdf", b"data")
assert excinfo.value is original
assert fake.funcs() == ["mkdir", "open_file", "remove"]
def test_passes_credentials_to_every_call(self, fake: FakeSmbClient, share_root: Path) -> None:
_hotfolder(login="user", password="secret").put("a.pdf", b"1")
assert [kwargs for _, _, kwargs in fake.calls] == [{"username": "user", "password": "secret"}] * 2
class TestPutOverwrite:
def test_existing_file_is_overwritten(self, fake: FakeSmbClient, share_root: Path) -> None:
"""Как в старом АРМ (решение владельца 09.10.2026): повтор задания перезаписывает входящий файл."""
hf = _hotfolder()
hf.put("job.pdf", b"first, longer")
hf.put("job.pdf", b"second")
assert fake._local(INPUT + "\\job.pdf").read_bytes() == b"second"
assert "rename" not in fake.funcs() and "replace" not in fake.funcs()
```
В `TestCleanupDetails.test_orphan_part_and_done_marker_are_removed` → переименовать в
`test_done_marker_is_removed_and_tmp_is_not_touched`: `.part` в `Tmp` **остаётся**,
`removed == [f"{INPUT}\\{name}", f"{INPUT}\\{name}.done"]`, assert `(root / "Tmp" / f".{name}.part").exists()`.
`test_passes_credentials_to_every_call` (cleanup): `["scandir", "remove", "remove", "rmdir", "remove", "remove"]`.
`test_missing_answer_dir_and_files_are_fine`: `["scandir", "remove", "remove"]`.
- [ ] **Step 2: Прогнать — ожидаемо падают**
Run: `cd api && .venv/bin/pytest -q tests/unit/test_smb_hotfolder.py`
Expected: FAIL (`TestPut`, `TestPutOverwrite`, `TestCleanupDetails`).
- [ ] **Step 3: Реализация `smb.py`**
`__init__`: убрать параметр `tmp_dir` и `self.tmp_dir`. `put`:
```python
def put(self, name: str, data: bytes) -> None:
"""Записать `<input>\\<name>` сразу, открытием `wb` (как в старом АРМ, решение владельца 09.10.2026): временного
файла и rename нет, существующий файл перезаписывается. `smbclient.open_file` открывает файл монопольно — пока
запись идёт, ИС его не прочитает (ASSUMPTION(OQ-79)). Имя проверяется до любого обращения к шаре. При ошибке
записи недописанный файл снимается только при включённой очистке (`SCAN_CLEANUP`, R-3); ошибка снятия исходную
не маскирует. Нет корня — `SMBOSError` (ENOENT) от `mkdir`.
"""
validate_name(name)
self._ensure_dir(self.input_dir)
target = ntpath.join(self.input_dir, name)
try:
with smbclient.open_file(target, mode="wb", **self._credentials) as fd:
fd.write(data)
except BaseException:
self._drop_partial(target)
raise
```
`_drop_part` переименовать в `_drop_partial` (тело то же, докстринг «недописанный входящий файл»). В `cleanup` из
кортежа путей убрать `ntpath.join(self.tmp_dir, f".{name}.part")`; докстринг — «ответ из `output`, входящий файл и
`.done`». Импорт `NtStatus` и `_has_errno(..., EEXIST ...)` в `put` больше не нужны — удалить, если не используются
(`_NOT_READY` использует `NtStatus` — оставить). Докстринг модуля: «файл пишется сразу в `\\<host>\<SMB_PATH>\<input_dir>\<имя>`
(`wb`, перезапись, как в старом АРМ)»; убрать упоминания `tmp_dir`/`.part`/OQ-79-rename. Комментарий `noqa: PLR0913`
у `__init__` оставить, если ruff его требует (параметров по-прежнему > 5).
- [ ] **Step 4: Прогнать**
Run: `cd api && .venv/bin/pytest -q tests/unit/test_smb_hotfolder.py`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway add api/src/gateway/smb.py api/tests/unit/test_smb_hotfolder.py
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway commit -m "feat(smb): запись прямо в Input с перезаписью, как в старом АРМ (#26)"
```
### Task 2: `LocalHotFolder`, фабрика, настройки
**Files:**
- Modify: `api/src/gateway/hotfolder.py` (докстринг модуля, `LocalHotFolder` докстринг, `put`, удалить `_open_part`/`_replace_part`, `_drop_part` → `_drop_partial`, `cleanup`, `_NO_HARD_LINKS`)
- Modify: `api/src/gateway/__init__.py` (не передавать `SCAN_TMP_DIR`), `api/src/core/config.py` (`SCAN_TMP_DIR` удалить, `SCAN_TIMEOUT_SECONDS = 1200`, комментарий `SCAN_CLEANUP`)
- Test: `api/tests/unit/test_local_hotfolder.py`, `api/tests/unit/test_hotfolder_factory.py`, `api/tests/unit/test_config.py`, `api/tests/integration/test_roundtrip.py`
**Interfaces:**
- Consumes: `SmbHotFolder` без `tmp_dir` (Task 1).
- Produces: `LocalHotFolder(root, input_dir="Input", output_dir="Output", tmp_dir="Tmp", *, ...)` — сигнатура та же,
`tmp_dir` используется только эмулятором; `Settings` без `SCAN_TMP_DIR`.
- [ ] **Step 1: Тесты**
`test_local_hotfolder.py`: удалить `TestPutWithoutReplace`, `TestPartIsCreatedFresh` и из `TestPut` —
`test_leaves_no_part_files`, `test_part_is_written_in_tmp_not_in_input`, `test_existing_tmp_dir_is_fine`,
`test_custom_tmp_dir_name`, `test_cleans_part_file_on_failure`, `test_keeps_part_file_on_failure_when_cleanup_disabled`,
`test_successful_put_despite_final_unlink_failure`, `test_cleanup_failure_does_not_mask_original_error`;
`test_creates_input_and_tmp_dirs_only` → `test_creates_input_dir_only` (`sorted(p.name for p in root.iterdir()) == ["Input"]`).
Добавить в `TestPut`:
```python
def test_existing_file_is_overwritten(self, tmp_path: Path) -> None:
hf = LocalHotFolder(tmp_path, settle_seconds=0)
hf.put("job.pdf", b"first, longer")
hf.put("job.pdf", b"second")
assert (hf.input_dir / "job.pdf").read_bytes() == b"second"
def test_write_failure_keeps_partial_file_when_cleanup_disabled(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
hf = LocalHotFolder(tmp_path, settle_seconds=0)
monkeypatch.setattr(Path, "write_bytes", _failing_write)
with pytest.raises(OSError, match="диск полон"):
hf.put("job.pdf", b"data")
assert (hf.input_dir / "job.pdf").read_bytes() == b"da"
def test_write_failure_removes_partial_file_when_cleanup_enabled(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=True)
monkeypatch.setattr(Path, "write_bytes", _failing_write)
with pytest.raises(OSError, match="диск полон"):
hf.put("job.pdf", b"data")
assert not (hf.input_dir / "job.pdf").exists()
def test_remove_failure_does_not_mask_write_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
hf = LocalHotFolder(tmp_path, settle_seconds=0, cleanup=True)
monkeypatch.setattr(Path, "write_bytes", _failing_write)
monkeypatch.setattr(Path, "unlink", _failing_unlink)
with pytest.raises(OSError, match="диск полон"):
hf.put("job.pdf", b"data")
```
с модульными помощниками:
```python
def _failing_write(self: Path, data: bytes) -> int:
"""Недописанный файл: на диск попадают первые два байта, затем ошибка ввода-вывода."""
with open(self, "wb") as handle:
handle.write(data[:2])
raise OSError(errno.ENOSPC, "диск полон")
def _failing_unlink(self: Path, missing_ok: bool = False) -> None:
raise PermissionError(errno.EACCES, "нет прав")
```
`TestCleanup`: ожидания про `Tmp/.<name>.part` — `.part` **не удаляется** (файл в `Tmp` остаётся, если тест его создаёт).
`test_hotfolder_factory.py`: в `_local_settings` убрать `SCAN_TMP_DIR`; assert'ы `hf.tmp_dir == tmp_path / "Служебный"`
и `hf.tmp_dir == "...\\T"` удалить, для local — `assert hf.tmp_dir == tmp_path / "Tmp"` (умолчание эмулятора).
`test_config.py`: строки `assert s.SCAN_TMP_DIR == "Tmp"` → `assert not hasattr(s, "SCAN_TMP_DIR")`; добавить
`assert s.SCAN_TIMEOUT_SECONDS == 1200` в тест умолчаний (строка 32) и тест:
```python
def test_legacy_scan_tmp_dir_is_ignored(monkeypatch):
monkeypatch.setenv("SCAN_TMP_DIR", "Tmp")
s = Settings(_env_file=None)
assert not hasattr(s, "SCAN_TMP_DIR")
```
(обязательные переменные — как в соседних тестах файла; взять их фикстуру/хелпер). `test_roundtrip.py`: убрать
`stray_parts`, `tmp_dir` у объекта стенда, `tmp_dir=settings.SCAN_TMP_DIR` (эмулятору передать `"Tmp"`), докстринги
про `.part`; в сценарии очистки — `Input/` и `Output/<job_id>/` пусты.
- [ ] **Step 2: Прогнать — ожидаемо падают**
Run: `cd api && .venv/bin/pytest -q tests/unit/test_local_hotfolder.py tests/unit/test_hotfolder_factory.py tests/unit/test_config.py`
Expected: FAIL.
- [ ] **Step 3: Реализация**
`LocalHotFolder.put`:
```python
def put(self, name: str, data: bytes) -> None:
"""Записать `input_dir/<name>` сразу (`wb`, перезапись существующего, как в старом АРМ — решение владельца
09.10.2026). `Input` создаётся без родителей, корень — нет (нет корня — `FileNotFoundError`). При ошибке записи
недописанный файл снимается только при включённой очистке (R-3), ошибка снятия подавляется."""
validate_name(name)
self.input_dir.mkdir(exist_ok=True)
target = self.input_dir / name
try:
target.write_bytes(data)
except BaseException:
self._drop_partial(target)
raise
def _drop_partial(self, path: Path) -> None:
if self.cleanup_enabled:
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)
```
Удалить `_open_part`, `_replace_part`, `_NO_HARD_LINKS` и неиспользуемые импорты (`errno`, `os` — проверить ruff).
`cleanup`: кортеж путей без `self.tmp_dir / f".{name}.part"`. Докстринги модуля и класса: прямая запись в `Input`;
`tmp_dir` — служебный каталог эмулятора (сборка набора ответа), `put` его не использует.
`gateway/__init__.py`: в `LocalHotFolder(...)` не передавать `settings.SCAN_TMP_DIR` (умолчание `"Tmp"`), в
`SmbHotFolder(...)` убрать аргумент. `config.py`: удалить `SCAN_TMP_DIR`; `SCAN_TIMEOUT_SECONDS: PositiveFloat = 1200`
(комментарий: «20 мин, в паре со сроком ответа OCR ядра»); комментарий `SCAN_CLEANUP` — без `.part в Tmp`.
- [ ] **Step 4: Прогнать unit**
Run: `cd api && .venv/bin/pytest -q tests/unit`
Expected: PASS (включая `test_emulator.py` — эмулятор пользуется `tmp_dir`).
- [ ] **Step 5: Commit**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway add -A api
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway commit -m "feat(hotfolder): local тоже пишет прямо в Input; без SCAN_TMP_DIR; таймаут 1200 с (#26)"
```
### Task 3: Документация и полная проверка
**Files:**
- Modify: `README.md` (схема конвейера п. 5, раздел протокола горячих папок, «Очистка», таблица env: убрать `SCAN_TMP_DIR` шлюза, `SCAN_TIMEOUT_SECONDS` = `1200`, права на SMB), `CLAUDE.md` («Что это», «Горячие папки»), `api/.env.example` (`SCAN_TMP_DIR` — в блок эмулятора, `SCAN_TIMEOUT_SECONDS`), `openspec/changes/hotfolder-direct-write/tasks.md` (отметки)
- [ ] **Step 1: Правки текста**
`git grep -n "Tmp\|\.part\|SCAN_TMP_DIR\|SCAN_TIMEOUT_SECONDS\|300" -- README.md CLAUDE.md api/.env.example` и привести
каждое место к: «файл пишется сразу в `Input/<job_id>.<ext>` (`wb`, существующий перезаписывается, как в старом АРМ;
ИС не берёт файл, пока он открыт на запись — ASSUMPTION(OQ-79))»; `Tmp` и `SCAN_TMP_DIR` упоминаются только у
эмулятора; права учётки шары — запись в `Input`, чтение `Output`, удаление при `SCAN_CLEANUP`; таймаут 1200 с.
- [ ] **Step 2: Полная проверка**
Run: `make check-full` (из корня репозитория; нужен поднятый стенд `make up`)
Expected: exit 0. Вывод — в описание PR.
- [ ] **Step 3: Commit**
```bash
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway add -A README.md CLAUDE.md api/.env.example openspec
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/smb-direct-write/scan-gateway commit -m "docs: прямая запись в Input, SCAN_TMP_DIR только у эмулятора, таймаут 1200 с (#26)"
```