From 7ec3dd49fd6ad9bfc794daf48ac982c36b69f717 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 9 Oct 2026 16:28:31 +0400 Subject: [PATCH] plan: hotfolder-direct-write (#26) Co-Authored-By: Claude Opus 5.5 --- .../2026-10-09-hotfolder-direct-write.md | 359 ++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-09-hotfolder-direct-write.md diff --git a/docs/superpowers/plans/2026-10-09-hotfolder-direct-write.md b/docs/superpowers/plans/2026-10-09-hotfolder-direct-write.md new file mode 100644 index 0000000..ad24b24 --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-hotfolder-direct-write.md @@ -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` пишет `.` сразу в `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)" +```