# Запись в горячую папку прямо в 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` пишет `.` сразу в `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(\<имя>, mode="wb", **credentials)` / `open(/<имя>, "wb")`; существующий файл перезаписывается. - Временных файлов, `Tmp` и rename у шлюза нет; `Input` создаётся терпимо (`_ensure_dir` / `mkdir(exist_ok=True)`), корень — нет. - Ошибка записи: недописанный `/<имя>` снимается только при `cleanup=True`; ошибка снятия подавляется, поднимается исходная. - При `SCAN_CLEANUP=false` ничего не удаляется; `cleanup` удаляет `Output//`, `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: """Записать `\\` сразу, открытием `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` — оставить). Докстринг модуля: «файл пишется сразу в `\\\\\<имя>` (`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/..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//` пусты. - [ ] **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/` сразу (`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/.` (`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)" ```