diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a64cf1625..ed521bf0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,30 @@ jobs: - name: Unit tests run: make test + unit-windows: + name: unit tests (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Set up Python + run: uv python install 3.12 + + # No `make` on the Windows image — call uv directly. The lock is + # universal (it carries sys_platform == 'win32' resolution markers and + # pywin32, which portalocker requires on Windows), so --frozen resolves here. + - name: Install dependencies (frozen) + run: uv sync --frozen + + - name: Unit tests + run: uv run pytest tests/unit -q + unit-py313: name: unit tests (3.13) runs-on: ubuntu-latest diff --git a/benchmarks/run.py b/benchmarks/run.py index af14373b5..ad8296237 100644 --- a/benchmarks/run.py +++ b/benchmarks/run.py @@ -528,7 +528,15 @@ def _free_port(start: int) -> tuple[int, socket.socket]: for port in range(start, start + 400): s = socket.socket() try: - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # SO_REUSEADDR means the opposite thing on Windows: it lets a + # second socket bind a port this one already holds, so the + # hold-until-spawn above would stop excluding anyone and two + # concurrent runs would be handed the same port. Windows spells + # the exclusivity this probe needs SO_EXCLUSIVEADDRUSE. + if hasattr(socket, "SO_EXCLUSIVEADDRUSE"): + s.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) + else: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(("127.0.0.1", port)) s.listen(1) except OSError: diff --git a/docs/api.md b/docs/api.md index 656b4093e..ff00977c3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -304,7 +304,8 @@ file (`episode-.md` etc.). assistant emitted in this turn (OpenAI Chat Completions shape). **`tool_call_id`** — When `role: "tool"`, the `id` of the call this -message is the response to. +message is the response to. Required for `role: "tool"`; a tool row +without it is rejected with `422`. ### ContentItem @@ -1117,8 +1118,8 @@ the operational probes; clients that share a response parser with | `runs` | `list[RunSummary]` | One entry per strategy run *attempt*, not per dispatched route: `{run_id: string, status: string, error?: string}`. A strategy that retried before settling contributes multiple entries sharing one `event_id`. `status` is one of `running` / `success` / `failed` / `dead_letter` / `crashed`. Includes dead-lettered runs | **`not_dispatched`** means every subscriber was rejected by one of the -four dispatch gates (`_routes_to` / `enabled` / `applies_to` / -`Counter`). The most common cause is forgetting `"force": true` on a +five dispatch gates (event-class subscription / `_routes_to` / `enabled` / +`applies_to` / `Counter`). The most common cause is forgetting `"force": true` on a strategy that is `enabled=false` in `ome.toml` — e.g. triggering `reflect_episodes` without `force` while it is disabled in config returns `{"status": "not_dispatched", "dispatched": 0, "runs": []}` diff --git a/docs/cascade_runbook.md b/docs/cascade_runbook.md index 9d0ee527d..f7f34a439 100644 --- a/docs/cascade_runbook.md +++ b/docs/cascade_runbook.md @@ -235,9 +235,12 @@ error and silently see nothing. Workarounds: -- Rely on the scanner — at default 30 s interval, throughput is +- Rely on the scanner — at the default 30 s interval, throughput is bounded but eventually-consistent. -- Drop the scan interval to ~5 s if the memory root is small. +- Shorten the interval if the memory root is small: + `scan_interval_seconds = 5.0` under `[cascade]` in `everos.toml`, or + `EVEROS_CASCADE__SCAN_INTERVAL_SECONDS=5`. Every sweep stats every md + file, so over a slow mount a short interval is a steady I/O cost. - With no server running, run `everos cascade sync` explicitly after batch edits. A running server picks them up itself, and `sync` refuses to run next to it (exit code 3): two processes writing the same index insert @@ -303,7 +306,7 @@ and `everos.memory.cascade.worker.CascadeWorker`: | Knob | Default | Effect | |---|---|---| -| `scan_interval_seconds` | 30 | Scanner sweep cadence | +| `scan_interval_seconds` | 30 | Scanner sweep cadence — also settable under `[cascade]` | | `worker_batch_size` | 50 | Rows claimed per worker cycle | | `worker_max_retry` | 3 | Inline retries before `mark_failed(retryable=TRUE)` | | `worker_poll_interval_seconds` | 1 | Idle wait between empty drain attempts | diff --git a/docs/index.md b/docs/index.md index 466a2d8f6..e5bd160d1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,6 +49,8 @@ specific thing (drain a queue, recover from a stuck row, etc.). | Doc | Purpose | |---|---| | [cascade_runbook.md](cascade_runbook.md) | Cascade subsystem ops — drain queue, recover stuck rows | +| [windows.md](windows.md) | Install and run EverOS on Windows — natively or via WSL2 | +| [windows.zh.md](windows.zh.md) | Chinese mirror of the Windows / WSL2 guide | | [github-sync.md](github-sync.md) | Guardrails for refreshing GitHub from internal exports without overwriting GitHub-only workflow files | | [benchmarks/README.md](../benchmarks/README.md) | LoCoMo benchmark — run and evaluate | diff --git a/docs/windows.md b/docs/windows.md new file mode 100644 index 000000000..0038ac985 --- /dev/null +++ b/docs/windows.md @@ -0,0 +1,291 @@ +# Running EverOS on Windows + +> Also available in Chinese: [windows.zh.md](windows.zh.md) + +EverOS runs on Windows in two ways. **Natively** — `pip install everos` +on a stock Windows 11 machine, verified end to end and covered by the +`unit tests (Windows)` CI job; that path is [Native Windows](#native-windows) +and is where to start. Or under **WSL2** — a real Linux kernel, so the +storage stack (file locking, LanceDB, inotify) behaves exactly as it does +on a Linux server; the rest of this page is that install, including the +parts that cannot be scripted and the one failure that is silent. + +## Table of contents + +- [Before you start](#before-you-start) + - [Will this machine need a reboot?](#will-this-machine-need-a-reboot) +- [Install](#install) + - [Phase 1 — WSL2 and the distro](#phase-1--wsl2-and-the-distro) + - [Phase 2 — EverOS inside the distro](#phase-2--everos-inside-the-distro) + - [Scripted install](#scripted-install) +- [Verify](#verify) +- [Where the memory root must live](#where-the-memory-root-must-live) +- [Office document support](#office-document-support) +- [Reaching the API from Windows](#reaching-the-api-from-windows) +- [Troubleshooting](#troubleshooting) +- [Native Windows](#native-windows) + +## Before you start + +| Requirement | Notes | +|---|---| +| Windows 11, or Windows 10 build 19041+ | `winver` to check | +| Administrator rights | Enabling the WSL feature needs elevation | +| Hardware virtualization enabled in firmware | Task Manager → Performance → CPU → "Virtualization: Enabled" | +| ~3 GB disk for the distro, ~1 GB more with LibreOffice | | + +On a managed corporate machine, Hyper-V and the virtual machine platform +are sometimes blocked by policy. That is a hard stop — it needs IT, not a +workaround. + +### Will this machine need a reboot? + +`wsl --install` enables the `VirtualMachinePlatform` Windows feature, and +enabling it requires a restart. But the feature is often **already on** — +Docker Desktop, Hyper-V, Windows Sandbox, the Android emulator and +virtualization-based security all turn it on. Check before you plan around +a reboot: + +```powershell +(Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform).State +``` + +- `Enabled` → no reboot; the whole install runs in one pass. +- `Disabled` → one reboot, once, on this machine only. + +## Install + +### Phase 1 — WSL2 and the distro + +Run PowerShell **as Administrator**: + +```powershell +wsl --install --no-launch +``` + +`--no-launch` matters: without it, `wsl --install` opens the distro's +first-run wizard and blocks on an interactive "Enter new UNIX username" +prompt, which is what breaks unattended installs. + +Reboot if the preflight above said `Disabled`. Then create the distro +without the interactive account setup: + +```powershell +wsl --install -d Ubuntu --no-launch +ubuntu install --root +``` + +### Phase 2 — EverOS inside the distro + +```bash +wsl -d Ubuntu -u root -- bash -lc ' + set -euo pipefail + apt-get update -qq + apt-get install -y -qq python3-venv curl + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + uv venv --python 3.12 /opt/everos + uv pip install --python /opt/everos/bin/python everos +' +``` + +### Scripted install + +The two phases combine into one idempotent script. Re-running it after the +reboot picks up where it stopped. + +```powershell +# everos-setup.ps1 — run as Administrator +$ErrorActionPreference = 'Stop' + +# Phase 1: WSL platform. +if (-not (Get-Command wsl -ErrorAction SilentlyContinue) -or -not (wsl --version 2>$null)) { + wsl --install --no-launch + Write-Host 'WSL installed. Reboot, then re-run this script.' -ForegroundColor Yellow + exit 0 +} + +# Phase 2: distro. +if (-not (wsl -l -q | Select-String -Quiet 'Ubuntu')) { + wsl --install -d Ubuntu --no-launch + ubuntu install --root +} + +# Phase 3: EverOS. +wsl -d Ubuntu -u root -- bash -lc @' +set -euo pipefail +apt-get update -qq +apt-get install -y -qq python3-venv curl +curl -LsSf https://astral.sh/uv/install.sh | sh +export PATH="$HOME/.local/bin:$PATH" +uv venv --python 3.12 /opt/everos +uv pip install --python /opt/everos/bin/python everos +'@ + +Write-Host 'Done. Start with: wsl -d Ubuntu -- /opt/everos/bin/everos serve' -ForegroundColor Green +``` + +Detect WSL with `wsl --version`, not `wsl --status` — the latter's exit +code is not a reliable "is it installed" signal. + +## Verify + +```bash +wsl -d Ubuntu -- /opt/everos/bin/everos --version +wsl -d Ubuntu -- /opt/everos/bin/everos init +wsl -d Ubuntu -- /opt/everos/bin/everos serve +``` + +From there follow the [Quick Start](../README.md#quick-start) — inside the +distro everything behaves as on any Ubuntu host. + +## Where the memory root must live + +> [!IMPORTANT] +> Keep the memory root on the WSL2 filesystem (`/home/...`, `/opt/...`). +> **Never put it under `/mnt/c/`.** + +Filesystem events do not propagate from the Windows host into WSL2. If the +memory root sits on a `/mnt/c` mount, the cascade watcher starts without +error, logs normally, and receives **zero events** — edits to markdown +files never reach the index, and searches silently answer from stale data. +Nothing in the logs says so. + +If you must keep files on the Windows side, fall back to polling: + +| Option | How | +|---|---| +| Scanner sweep (default 30 s) | Already on; bounded but eventually consistent | +| Faster sweep | Drop the scan interval to ~5 s for a small root | +| Explicit sync | `everos cascade sync` after batch edits | + +Details in the [cascade runbook](cascade_runbook.md#wsl2--network-mounts). + +## Office document support + +Install the **Linux** LibreOffice inside the distro — the Windows build +cannot serve a process running in WSL2: + +```bash +wsl -d Ubuntu -u root -- apt-get install -y libreoffice +``` + +The parser resolves the binary with `shutil.which("soffice")`, a plain +PATH lookup inside the distro, so a Windows `soffice.exe` is never found. +Without it, office uploads return `503 CAPABILITY_UNAVAILABLE`; see +[multimodal.md](multimodal.md#libreoffice-office-documents-only). + +## Reaching the API from Windows + +Yes — WSL2 forwards `localhost` by default (`localhostForwarding`), so a +server started inside the distro answers on the same port from Windows. +`everos serve` binds `127.0.0.1` inside the distro; the forwarder relays +to it, so the loopback default does not need changing: + +```bash +wsl -d Ubuntu -- /opt/everos/bin/everos serve # listens inside the distro +curl http://localhost:8000/health # from Windows PowerShell +``` + +If that call does not connect, the usual causes are a VPN client taking +over the WSL network, or a wedged forwarder. Restart WSL first: + +```powershell +wsl --shutdown # next `wsl` command restarts it and rebuilds the relay +``` + +> [!WARNING] +> Do not reach for `host = "0.0.0.0"` as a fix. **EverOS ships no +> authentication of its own** — loopback is what keeps the API private +> (see [SECURITY.md](../SECURITY.md)). How far `0.0.0.0` actually reaches +> depends on the WSL networking mode, and the two differ sharply: +> +> | `networkingMode` in `.wslconfig` | What `0.0.0.0` exposes | +> |---|---| +> | `nat` (default) | The distro's private virtual network plus the Windows host. Other machines on your LAN cannot reach it without an explicit `netsh portproxy`. | +> | `mirrored` (WSL 2.0+) | Every interface the Windows machine has — **the API becomes reachable from the LAN**. | +> +> Only bind `0.0.0.0` once your own gateway or auth layer sits in front. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `wsl --install` hangs on "Enter new UNIX username" | `--no-launch` omitted | Ctrl-C, re-run with `--no-launch`, then `ubuntu install --root` | +| "The virtual machine could not be started" | Virtualization off in firmware | Enable VT-x / AMD-V in BIOS/UEFI | +| `WslRegisterDistribution failed with error: 0x80370102` | Same, or Hyper-V blocked by policy | Firmware setting, or IT | +| Install succeeded but nothing runs after reboot | Script not re-run | Re-run it; it is idempotent | +| Markdown edits never appear in search | Memory root on `/mnt/c` | Move it into the distro filesystem | +| Office upload returns `503` | LibreOffice missing **inside** the distro | `apt-get install -y libreoffice` | + +## Native Windows + +EverOS runs directly on Windows. Verified on a stock Windows 11 Enterprise +laptop (Intel Core Ultra 7 155H, 32 GB, no Visual C++ Redistributable +installed) with Python 3.12 from `uv`: + +- `pip install` of the built wheel into a plain venv (dependencies from + PyPI: pyarrow 25.0.1, `msvc-runtime` 14.44, lancedb 0.34) → + `everos init` → put an LLM `api_key` in `everos.toml` (the server refuses + to start without one, and `init` says so) → `everos server start`: healthy + after 35 s, `/add` and `/search` answered. No other manual step. The + Windows-only dependencies are `msvc-runtime`, which supplies the C++ + runtime `greenlet` needs (details below), and `pywin32`, which + `portalocker` uses for file locking. +- Test suites: the unit suite runs green in CI on `windows-latest` + (`unit tests (Windows)`, 2583 passed / 4 skipped — the same count as + Linux); on that machine, integration **183 passed / 5 skipped** and live + LLM (`slow`) **28 passed / 1 skipped**, both under Python 3.12. +- All four memory kinds — episode, profile, agent case, agent skill — were + produced by a Tier 3 server (real LLM, embedding and rerank providers) and + read back through `/get` and `/search`. +- A 10-hour soak (about 78 000 markdown entries written and rewritten, + 16 000 searches, 2 300 `/add` calls through the extraction path, two + concurrent `everos cascade sync` processes on the same tree) ended with + the index intact: every table opens, schemas verify, and every well-formed + entry had its row — the only markdown entries without one were the + deliberately malformed files the run seeds. RSS levelled at about 2.3 GB + after three hours; the LanceDB directory peaked at 6.7 GB and reclaimed + to 2.7 GB (437 MB of live data). Two findings from that run are tracked + separately and are not Windows-specific: concurrent `cascade sync` + processes can insert the same row twice (about 4.5 % duplicate rows after + 10 hours, no corruption), and request latency degrades under sustained + write load. + +Python: 3.12 is what ran on that machine; 3.13 and 3.14 (regular build) +pass the same suites in CI and on macOS without changes; 3.11 is refused by +`requires-python` and by the PEP 695 syntax in `src/`; the free-threaded +3.14t build has no `lancedb` wheel. + +Windows specifics worth knowing: + +- `os.replace` fails with `PermissionError` while another process — the + cascade worker, or Defender scanning a fresh file — holds the target open. + The markdown writer retries with backoff (about 2.5 s of patience) and + logs each retry at debug level; the soak's load generator, which uses + the same idiom, hit 49 such violations and recovered from all of them. +- Windows Search indexes everything under `%USERPROFILE%`. A memory root + there costs about one CPU core of `SearchIndexer` under heavy writes; put + the root elsewhere or exclude the directory from indexing. +- File-change events come from `ReadDirectoryChangesW`, which can report a + rename as two events. The cascade scanner reconciles against the disk. +- Both machines that ran the suites had long paths enabled + (`HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled=1`; + GitHub's image sets it, a stock install does not). Memory roots nest + several directories deep and Lance index directories carry UUID names, + so a root under a long profile path can cross 260 characters — enable + long paths or keep the root short. + +The one prerequisite that used to bite on a clean machine is the +**Microsoft Visual C++ Redistributable (x64)**. `greenlet`, which +SQLAlchemy's async engine depends on, is a C++ extension whose wheel does +not bundle the runtime, so without it every SQLite call fails with a cryptic +`DLL load failed while importing _greenlet`. GitHub's CI image has the +redistributable preinstalled, which is why CI never caught this. EverOS now +carries the runtime itself: the Windows-only `msvc-runtime` dependency puts +the DLLs in `sys.prefix`, and `everos/__init__.py` registers that directory +with the DLL loader before anything else is imported. Nothing to install, +no administrator rights needed. One dependency to watch: `msvc-runtime` +ships wheels only, one per CPython minor version, so a Python newer than +its latest wheel cannot install EverOS on Windows until upstream publishes +one. diff --git a/docs/windows.zh.md b/docs/windows.zh.md new file mode 100644 index 000000000..73b6a72b4 --- /dev/null +++ b/docs/windows.zh.md @@ -0,0 +1,265 @@ +# 在 Windows 上跑 EverOS + +Windows 上有两条路。**直接装**:在干净的 Windows 11 机器上 `pip install everos`,已完整验过,CI 也有 +`unit tests (Windows)` 这一档——见文末「直接在 Windows 上装行不行」,建议从这里开始。**走 WSL2**:一套真 +Linux 内核,存储栈(文件锁、LanceDB、inotify)与 Linux 服务器上完全一致;这篇其余部分讲的是这条路。 + +WSL2 是 Windows 自带的一套真 Linux 内核,不是模拟器也不是虚拟机软件。装完之后 +EverOS 就跟跑在一台 Ubuntu 服务器上没区别,文件锁、向量索引、文件监听全部是 +Linux 原生行为。所以这条路的好处不是「能跑」,是**它和我们测过的环境完全一致**。 + +这篇写完整的安装过程,包括脚本绕不过去的那一步,和一个不看文档必踩、踩了还没有 +任何报错的坑。 + +## 目录 + +- [开始之前](#开始之前) + - [这台机器要不要重启](#这台机器要不要重启) +- [安装](#安装) + - [第一步:装 WSL2 和 Ubuntu](#第一步装-wsl2-和-ubuntu) + - [第二步:在 Ubuntu 里装 EverOS](#第二步在-ubuntu-里装-everos) + - [一个脚本跑完](#一个脚本跑完) +- [确认装好了](#确认装好了) +- [数据目录千万别放 C 盘](#数据目录千万别放-c-盘) +- [Office 文档支持](#office-文档支持) +- [从 Windows 这边访问 API](#从-windows-这边访问-api) +- [出问题了](#出问题了) +- [直接在 Windows 上装行不行](#直接在-windows-上装行不行) + +## 开始之前 + +| 要求 | 怎么确认 | +|---|---| +| Windows 11,或 Windows 10 build 19041 以上 | 运行 `winver` | +| 管理员权限 | 开 WSL 功能需要提权 | +| CPU 虚拟化已在固件里打开 | 任务管理器 → 性能 → CPU → 「虚拟化:已启用」 | +| 磁盘 3 GB 起,装 LibreOffice 再加 1 GB | | + +公司发的电脑上,Hyper-V 和虚拟机平台有可能被组策略锁死。这种情况没有绕法,得找 IT。 + +### 这台机器要不要重启 + +`wsl --install` 要打开 Windows 的 `VirtualMachinePlatform` 功能,而打开它需要重启。 + +但这个功能**很多机器上本来就是开的** —— 装过 Docker Desktop、开过 Hyper-V、用过 +Windows 沙盒或安卓模拟器、公司推过基于虚拟化的安全策略,任意一条命中就已经开了。 +先查一下,别白白安排一次重启: + +```powershell +(Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform).State +``` + +- 结果是 `Enabled` → 不用重启,整个安装一口气跑完。 +- 结果是 `Disabled` → 要重启一次,而且只有这一次,以后升级 EverOS 都不用。 + +## 安装 + +### 第一步:装 WSL2 和 Ubuntu + +用**管理员身份**打开 PowerShell: + +```powershell +wsl --install --no-launch +``` + +`--no-launch` 不能省。不加的话 `wsl --install` 装完会直接把 Ubuntu 拉起来,停在 +「Enter new UNIX username」等你输用户名 —— 脚本就卡死在这儿了。 + +如果上面预检查出来是 `Disabled`,现在重启。然后创建发行版,同样跳过交互: + +```powershell +wsl --install -d Ubuntu --no-launch +ubuntu install --root +``` + +### 第二步:在 Ubuntu 里装 EverOS + +```bash +wsl -d Ubuntu -u root -- bash -lc ' + set -euo pipefail + apt-get update -qq + apt-get install -y -qq python3-venv curl + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + uv venv --python 3.12 /opt/everos + uv pip install --python /opt/everos/bin/python everos +' +``` + +### 一个脚本跑完 + +上面两步可以合成一个脚本。它是幂等的 —— 重启之后原样再跑一遍,会从断掉的地方接上, +不会重复装。 + +```powershell +# everos-setup.ps1 —— 用管理员身份运行 +$ErrorActionPreference = 'Stop' + +# 第一步:WSL 平台 +if (-not (Get-Command wsl -ErrorAction SilentlyContinue) -or -not (wsl --version 2>$null)) { + wsl --install --no-launch + Write-Host 'WSL 装好了。重启电脑,然后再跑一遍这个脚本。' -ForegroundColor Yellow + exit 0 +} + +# 第二步:Ubuntu +if (-not (wsl -l -q | Select-String -Quiet 'Ubuntu')) { + wsl --install -d Ubuntu --no-launch + ubuntu install --root +} + +# 第三步:EverOS +wsl -d Ubuntu -u root -- bash -lc @' +set -euo pipefail +apt-get update -qq +apt-get install -y -qq python3-venv curl +curl -LsSf https://astral.sh/uv/install.sh | sh +export PATH="$HOME/.local/bin:$PATH" +uv venv --python 3.12 /opt/everos +uv pip install --python /opt/everos/bin/python everos +'@ + +Write-Host '装完了。启动:wsl -d Ubuntu -- /opt/everos/bin/everos serve' -ForegroundColor Green +``` + +判断 WSL 装没装用 `wsl --version`,别用 `wsl --status` —— 后者的退出码在几种情况下 +都是 0,判断不出来。 + +## 确认装好了 + +```bash +wsl -d Ubuntu -- /opt/everos/bin/everos --version +wsl -d Ubuntu -- /opt/everos/bin/everos init +wsl -d Ubuntu -- /opt/everos/bin/everos serve +``` + +到这儿就可以照着 [快速开始](../README.md#quick-start) 往下走了。进了 Ubuntu 之后, +所有操作和在一台普通 Ubuntu 机器上完全一样。 + +## 数据目录千万别放 C 盘 + +> [!IMPORTANT] +> EverOS 存记忆的目录(memory root)要放在 Ubuntu 自己的文件系统里,比如 +> `/home/...` 或 `/opt/...`。**不要放在 `/mnt/c/` 底下。** + +这是这条路上唯一一个**不报错的坑**,所以单独拿出来讲。 + +EverOS 有个后台组件一直盯着这个目录里的 md 文件,文件一改就更新索引。而 Windows +那边的文件改动**不会通知到 WSL2 里面** —— 这是 WSL2 的机制限制,不是 EverOS 的 bug。 + +后果是:目录放在 `/mnt/c` 上时,这个监听组件会正常启动、正常打日志、看起来一切健康, +但一个文件事件都收不到。你改了 md 文件,索引不动;然后搜索安安静静地返回旧数据。 +日志里没有任何一行提示这件事。 + +如果因为别的原因必须把文件放在 Windows 那边,就只能退回定时扫描: + +| 办法 | 怎么做 | +|---|---| +| 用默认的定时扫描(30 秒一轮) | 本来就开着,慢一点但最终会同步上 | +| 把间隔调短 | 目录不大的话调到 5 秒左右 | +| 改完手动同步一次 | `everos cascade sync` | + +细节见 [cascade 运维手册](cascade_runbook.md#wsl2--network-mounts)。 + +## Office 文档支持 + +要解析 Word / Excel / PPT,得在 Ubuntu 里装 **Linux 版** LibreOffice: + +```bash +wsl -d Ubuntu -u root -- apt-get install -y libreoffice +``` + +Windows 版的 LibreOffice 装了也没用。EverOS 是在 Ubuntu 内部用 `shutil.which("soffice")` +去 PATH 里找这个程序的,找不到 Windows 那边的 `soffice.exe`。 + +没装的话,上传 Office 文件会返回 `503 CAPABILITY_UNAVAILABLE`。其他格式(图片、 +PDF、音频)不受影响,见 [multimodal.md](multimodal.md#libreoffice-office-documents-only)。 + +## 从 Windows 这边访问 API + +能访问,不用额外配置。WSL2 默认开着 localhost 转发,Ubuntu 里监听的端口,Windows +上用同一个端口就能连。`everos serve` 在 Ubuntu 内部绑的是 `127.0.0.1`,转发照样能 +中继过去,所以这个默认值不用改: + +```bash +wsl -d Ubuntu -- /opt/everos/bin/everos serve # 在 Ubuntu 里监听 +curl http://localhost:8000/health # Windows 这边直接连 +``` + +连不上的话,最常见的两个原因是 VPN 客户端接管了 WSL 的网络,或者转发进程卡住了。 +先重启一下 WSL: + +```powershell +wsl --shutdown # 下次跑 wsl 命令会自动重启,转发也跟着重建 +``` + +> [!WARNING] +> 别拿 `host = "0.0.0.0"` 当解决办法。**EverOS 自己不带任何鉴权** —— 绑回环地址 +> 就是它保持私有的唯一手段(见 [SECURITY.md](../SECURITY.md))。而且 `0.0.0.0` +> 到底暴露到哪,取决于 WSL 的网络模式,两种差别很大: +> +> | `.wslconfig` 里的 `networkingMode` | 绑 `0.0.0.0` 会暴露给谁 | +> |---|---| +> | `nat`(默认) | 只到 Ubuntu 的虚拟网段和 Windows 本机。局域网里的其他机器连不上,除非你手动配了 `netsh portproxy`。 | +> | `mirrored`(WSL 2.0 以上) | Windows 这台机器的**所有网卡** —— 这个无鉴权的 API 就挂到局域网上了。 | +> +> 只有在自己的网关或鉴权层已经挡在前面时,才用 `0.0.0.0`。 + +## 出问题了 + +| 你看到的现象 | 原因 | 怎么办 | +|---|---|---| +| `wsl --install` 卡在 「Enter new UNIX username」 | 漏了 `--no-launch` | Ctrl-C,加上 `--no-launch` 重跑,然后 `ubuntu install --root` | +| 提示「无法启动虚拟机」 | 固件里虚拟化没开 | 进 BIOS/UEFI 打开 VT-x 或 AMD-V | +| 报错 `WslRegisterDistribution failed with error: 0x80370102` | 同上,或者 Hyper-V 被组策略禁了 | 改固件设置,或找 IT | +| 重启完就没动静了 | 脚本没再跑一遍 | 再跑一遍,它是幂等的 | +| 改了 md 文件,搜索还是旧内容 | 数据目录放在 `/mnt/c` 上了 | 挪进 Ubuntu 的文件系统 | +| 传 Office 文件返回 `503` | Ubuntu **里面**没装 LibreOffice | `apt-get install -y libreoffice` | + +## 直接在 Windows 上装行不行 + +行。在一台干净的 Windows 11 企业版笔记本(Intel Core Ultra 7 155H、32 GB、没装 Visual C++ +运行库)上用 `uv` 的 Python 3.12 验过: + +- 在一个干净的 venv 里 `pip install` 构建出的轮子(依赖从 PyPI 解析:pyarrow 25.0.1、`msvc-runtime` + 14.44、lancedb 0.34)→ `everos init` → 在 `everos.toml` 里填上 LLM 的 `api_key`(不填服务会拒绝启动, + `init` 也会这么提示)→ `everos server start`:35 秒后健康,`/add`、`/search` 正常。除此之外没有 + 手工步骤。Windows 专用依赖有两个:`msvc-runtime`(提供 `greenlet` 需要的 C++ 运行库,见下文)和 + `pywin32`(`portalocker` 用它做文件锁)。 +- 测试:单测在 CI 的 `windows-latest` 上是绿的(`unit tests (Windows)`,2583 通过 / 4 跳过, + 与 Linux 同数);这台机器上跑了集成 **183 通过 / 5 跳过** 和真实 LLM 的 `slow` 用例 + **28 通过 / 1 跳过**,都在 Python 3.12 下。 +- 四类记忆——episode、profile、agent case、agent skill——都由 Tier 3 服务(真实 LLM、embedding、 + rerank)产出,并通过 `/get` 和 `/search` 取回。 +- 10 小时浸泡(写入和改写约 78 000 条 md 条目、16 000 次检索、2 300 次走抽取路径的 `/add`, + 另有两个并发的 `everos cascade sync` 进程在同一棵目录树上不停跑)结束时索引完好:每张表都能打开、 + schema 校验通过、每一条格式正确的条目都有对应的行——没有行的只有这轮故意撒进去的畸形文件; + RSS 三小时后稳定在约 2.3 GB;LanceDB 目录峰值 6.7 GB,回收到 + 2.7 GB(真实数据 437 MB)。这次浸泡另外抓到两个与 Windows 无关的问题,单独跟踪:并发的 + `cascade sync` 进程会把同一行插两次(10 小时后约 4.5% 重复行,没有损坏);持续写负载下请求 + 延迟会变差。 + +Python:这台机器上跑的是 3.12;3.13 和 3.14(普通构建)在 CI 和 macOS 上不改任何东西就能过同一套 +测试;3.11 被 `requires-python` 和 `src/` 里的 PEP 695 语法拒绝;free-threaded 的 3.14t 没有 +`lancedb` 轮子。 + +Windows 上值得知道的几件事: + +- 另一个进程(cascade worker,或者 Defender 正在扫刚写好的文件)打开着目标文件时,`os.replace` + 会报 `PermissionError`。md 写入器会带退避重试(总共约 2.5 秒的耐心),每次重试记一条 debug 日志; + 浸泡的加载器用的是同一套写法,撞上 49 次,全部重试成功。 +- Windows 搜索会索引 `%USERPROFILE%` 下的所有东西。把记忆目录放在那里,重写压力下会多花大约 + 一个核给 `SearchIndexer`;把目录放到别处,或者把它从索引里排除。 +- 文件变更事件来自 `ReadDirectoryChangesW`,一次重命名可能被报成两个事件。cascade 的扫描器会 + 按磁盘实际状态对账。 +- 跑过测试的两台机器都开着长路径(`HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled=1`; + GitHub 的镜像默认开,干净安装的 Windows 默认不开)。记忆目录嵌套好几层,Lance 的索引目录又是 + UUID 命名,放在很长的用户目录下可能超过 260 个字符——要么开长路径,要么把目录放短一点。 + +以前在干净机器上会踩的那个前置条件是 **Microsoft Visual C++ 运行库(x64)**。SQLAlchemy 的 +异步引擎依赖 `greenlet`,它是 C++ 扩展、轮子不自带运行库,缺了之后每一次 SQLite 调用都会报一句 +看不懂的 `DLL load failed while importing _greenlet`。GitHub 的 CI 镜像预装了这个运行库,所以 CI +抓不到。现在 EverOS 自己带着它:Windows 专用的 `msvc-runtime` 依赖把 DLL 放进 `sys.prefix`, +`everos/__init__.py` 在导入任何东西之前把这个目录注册给 DLL 加载器。不用装任何东西,也不需要管理员。 +要留意的一点:`msvc-runtime` 只发轮子、按 CPython 小版本发,比它最新轮子更新的 Python 在上游发布之前 +装不了 EverOS 的 Windows 版。 diff --git a/pyproject.toml b/pyproject.toml index 087b6760e..c400d3f65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,10 @@ dependencies = [ # OME (Offline Memory Evolution) async scheduler & file I/O "apscheduler>=3.10.4,<4.0", # async strategy scheduler chassis "portalocker>=2.8.2", # cross-platform file lock for single-engine guard + # Windows only: the MSVC C++ runtime (msvcp140.dll et al.) that greenlet's + # wheel links against and a stock Windows does not ship. It lands in + # sys.prefix; everos/__init__.py registers that dir with the DLL loader. + "msvc-runtime>=14.44; sys_platform == 'win32'", "watchfiles>=0.21.0", # native fs watcher for config hot reload "anyio>=4.0", # Async file I/O (anyio.Path, to_thread.run_sync) for the markdown layer diff --git a/src/everos/__init__.py b/src/everos/__init__.py index c04b28d18..78c2b1ef8 100644 --- a/src/everos/__init__.py +++ b/src/everos/__init__.py @@ -1,5 +1,10 @@ """everos — md-first memory extraction framework.""" +from __future__ import annotations + +import os +import site +import sys from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version @@ -9,3 +14,49 @@ # Editable install without dist-info, or running from a source tree that # was never installed. Fall back to a sentinel rather than crash imports. __version__ = "0.0.0+unknown" + + +# Handles returned by ``os.add_dll_directory``; a collected handle would +# silently unregister its directory again, so they live for the process. +_dll_dir_handles: list[object] = [] + + +def _register_runtime_dll_dirs( + prefix: str = sys.prefix, user_base: str | None = None +) -> list[str]: + """On Windows, let compiled extensions find the MSVC C++ runtime. + + ``greenlet`` -- under SQLAlchemy's async engine, so under every SQLite + call -- is a C++ extension whose wheel does not bundle ``msvcp140.dll``, + and a stock Windows install does not ship it: ``import greenlet`` dies + with ``DLL load failed while importing _greenlet``. The ``msvc-runtime`` + dependency drops the runtime DLLs into ``sys.prefix`` and its ``Scripts`` + dir, but that alone is not enough: a venv's ``python.exe`` is a launcher + (uv's is a trampoline), so the loader's "application directory" is the + base interpreter's and the DLLs sit unseen next door. Registering the + directories with ``os.add_dll_directory`` fixes the search path for every + extension imported afterwards -- which is why this runs from the package + ``__init__``: nothing in everos is imported before it. + + Feature-detected rather than platform-checked: ``os.add_dll_directory`` + exists only on Windows, so elsewhere this is a no-op. Returns the + directories it registered. + """ + add = getattr(os, "add_dll_directory", None) + if add is None: + return [] + # ``pip install`` falls back to a per-user install when site-packages is + # not writable (Python under Program Files); the wheel's data files then + # land under ``site.getuserbase()`` rather than ``sys.prefix``. + if user_base is None: + user_base = site.getuserbase() + registered: list[str] = [] + for base in dict.fromkeys((prefix, user_base)): + for d in (base, os.path.join(base, "Scripts")): + if os.path.isfile(os.path.join(d, "msvcp140.dll")): + _dll_dir_handles.append(add(d)) + registered.append(d) + return registered + + +_register_runtime_dll_dirs() diff --git a/src/everos/config/default.toml b/src/everos/config/default.toml index 3dceee2f4..af2344401 100644 --- a/src/everos/config/default.toml +++ b/src/everos/config/default.toml @@ -207,6 +207,11 @@ optimize_prune_interval_seconds = 300.0 # under LanceDB's 7-day unverified window they then wait out the full 7 days. optimize_prune_retention_seconds = 60.0 optimize_rebuild_interval_seconds = 43200.0 +# How often the scanner walks the memory root for changes the watcher missed. +# On a mount that delivers no filesystem events (a Windows directory bound into +# WSL2 or a container) this is the edit-to-searchable latency. Every sweep also +# stats every md file, which over a slow mount is the cost of a short interval. +scan_interval_seconds = 30.0 [observability] # OpenTelemetry tracing export. Off by default; pure OTLP/HTTP, vendor-neutral diff --git a/src/everos/config/settings.py b/src/everos/config/settings.py index fc1be21ad..354390edb 100644 --- a/src/everos/config/settings.py +++ b/src/everos/config/settings.py @@ -549,12 +549,22 @@ class CascadeSettings(BaseModel): Full index rebuild per kind, which collapses the active index fragment count that every ``optimize()`` grows. Bounded by rebuild cost, not correctness — a missed sweep only defers cleanup. + + ``scan_interval_seconds``: + How often the scanner walks the memory root for what the watcher + missed: files written while the daemon was down, editors that + move-replace, and mounts that deliver no filesystem events at all (a + Windows directory bound into WSL2 or a container). On such a mount this + is the only path an md edit takes to the index, so it is the + edit-to-searchable latency. Every sweep also ``stat``-s every md file, + which over a slow mount is the cost to weigh against it. """ optimize_heartbeat_seconds: float = 60.0 optimize_prune_interval_seconds: float = 300.0 optimize_prune_retention_seconds: float = 60.0 optimize_rebuild_interval_seconds: float = 12 * 60 * 60.0 + scan_interval_seconds: float = Field(default=30.0, gt=0) class IndexSettings(BaseModel): diff --git a/src/everos/core/persistence/locking.py b/src/everos/core/persistence/locking.py index 71bb3ef7d..cf963980d 100644 --- a/src/everos/core/persistence/locking.py +++ b/src/everos/core/persistence/locking.py @@ -1,7 +1,8 @@ """Process-wide exclusive lock on a memory-root. -Uses ``fcntl.flock`` (POSIX advisory locking, available on Linux + macOS; -Windows is not supported — see project README on platform scope). The +Uses ``portalocker`` for the exclusive lock, which dispatches to +``fcntl.flock`` on POSIX and, on Windows, its default ``msvcrt.locking`` +locker — the same whole-file, released-on-process-exit semantics on both. The public surface is an :func:`contextlib.asynccontextmanager` so callers use ``async with memory_root_lock(mr):``; the underlying syscalls have no async equivalent so they run in a worker thread via @@ -21,13 +22,14 @@ from __future__ import annotations -import fcntl import os import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager import anyio +import portalocker +from portalocker.exceptions import AlreadyLocked from everos.core.observability.logging import get_logger @@ -50,7 +52,7 @@ alive but wedged inside its critical section, the only case this bounds — giving up sooner does not un-stick it: the error and the operator's next move (inspect the holding process) are the same at 5 minutes or 30. -``flock`` is released by the kernel on process exit, so a *dead* holder +The lock is released by the OS on process exit, so a *dead* holder never needs this. """ @@ -89,7 +91,7 @@ async def memory_root_lock( lock_path = memory_root.lock_file # Open the anchor file (create on first use). The fd, not the path, is - # what fcntl operates on. ``os.open`` is microsecond-fast but offloaded + # what the lock operates on. ``os.open`` is microsecond-fast but offloaded # for consistency with the rest of the lock acquisition flow. fd = await anyio.to_thread.run_sync( lambda: os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644) @@ -102,10 +104,10 @@ async def memory_root_lock( while True: try: await anyio.to_thread.run_sync( - fcntl.flock, fd, fcntl.LOCK_EX | fcntl.LOCK_NB + portalocker.lock, fd, portalocker.LOCK_EX | portalocker.LOCK_NB ) break - except BlockingIOError as exc: + except AlreadyLocked as exc: if not blocking: raise LockError( "another process already holds the memory-root lock " @@ -123,8 +125,8 @@ async def memory_root_lock( "timed out after " f"{time.monotonic() - started:.1f}s waiting for the " f"memory-root lock at {lock_path}. The holder is " - "still alive (the kernel releases a dead process's " - "flock automatically) — inspect the process holding " + "still alive (the OS releases a dead process's " + "lock automatically) — inspect the process holding " f"{lock_path} rather than retrying this one" ) from exc await anyio.sleep(_LOCK_POLL_INTERVAL_SECONDS) @@ -146,6 +148,6 @@ async def memory_root_lock( yield finally: try: - await anyio.to_thread.run_sync(fcntl.flock, fd, fcntl.LOCK_UN) + await anyio.to_thread.run_sync(portalocker.unlock, fd) finally: await anyio.to_thread.run_sync(os.close, fd) diff --git a/src/everos/core/persistence/markdown/writer.py b/src/everos/core/persistence/markdown/writer.py index f6af81709..ea4f995b6 100644 --- a/src/everos/core/persistence/markdown/writer.py +++ b/src/everos/core/persistence/markdown/writer.py @@ -31,7 +31,7 @@ Process-level coordination (multi-process writers against the same memory-root) remains the job of :func:`everos.core.persistence.locking.memory_root_lock`, which uses -``fcntl.flock``. The two locks compose: per-path async lock serialises +``portalocker``. The two locks compose: per-path async lock serialises tasks within one process, ``memory_root_lock`` serialises processes against each other. """ @@ -41,6 +41,7 @@ import asyncio import contextlib import os +import time import uuid from collections.abc import Mapping, Sequence from pathlib import Path @@ -49,6 +50,7 @@ import anyio from everos.core.errors import PathTraversalError +from everos.core.observability.logging import get_logger from ..memory_root import MemoryRoot from .entries import EntryId @@ -155,7 +157,7 @@ async def write(self, path: Path, content: str) -> Path: tmp = target.parent / f".{target.name}.tmp.{uuid.uuid4().hex}" try: await anyio.to_thread.run_sync(_write_and_fsync, tmp, content) - await anyio.to_thread.run_sync(os.replace, tmp, target) + await anyio.to_thread.run_sync(_replace_with_retry, tmp, target) except Exception: # Best-effort cleanup of the staging file on failure. await _unlink_quiet(tmp) @@ -338,6 +340,50 @@ async def _append_entries_unlocked( return await self.write_markdown(target, frontmatter=meta, body=body) +logger = get_logger(__name__) + + +_REPLACE_ATTEMPTS = 8 +_REPLACE_FIRST_BACKOFF_S = 0.02 # doubles each time: ~2.5 s of patience in total + + +def _replace_with_retry(tmp: Path, target: Path) -> None: + """``os.replace`` that outlasts a Windows sharing violation. + + On Windows a file some other process holds open cannot be replaced: the + cascade worker reading it, an antivirus scan right after the last write, + an editor with it open -- ``os.replace`` raises ``PermissionError`` + (WinError 5 / 32). On POSIX the first attempt succeeds and this is a plain + ``os.replace``; the one POSIX ``PermissionError`` (an immutable target, + macOS ``uchg``) is permanent and only costs the backoff before it + surfaces. Those holds last milliseconds, so a short + exponential backoff is the standard idiom (git, pip and uv all do it). + Only ``PermissionError`` is retried, each attempt is still one atomic + ``os.replace``, and after the budget the error propagates unchanged -- + nothing is swallowed. Sync on purpose: it runs in the same worker thread + as the fsync'ed staging write. + + Surfaced by the Windows soak, where the load feeder hit exactly this 62 s + in; the server's own writer here is the same primitive. + """ + delay = _REPLACE_FIRST_BACKOFF_S + for attempt in range(_REPLACE_ATTEMPTS): + try: + os.replace(tmp, target) + return + except PermissionError: + if attempt == _REPLACE_ATTEMPTS - 1: + raise + logger.debug( + "markdown_replace_retried", + target=str(target), + attempt=attempt + 1, + backoff_seconds=delay, + ) + time.sleep(delay) + delay *= 2 + + def _write_and_fsync(tmp: Path, content: str) -> None: """Sync helper: write + fsync the staging file. Offloaded to a thread.""" with open(tmp, "w", encoding="utf-8") as fh: diff --git a/src/everos/entrypoints/api/routes/memorize.py b/src/everos/entrypoints/api/routes/memorize.py index 0d1dd6512..c22c71100 100644 --- a/src/everos/entrypoints/api/routes/memorize.py +++ b/src/everos/entrypoints/api/routes/memorize.py @@ -14,7 +14,7 @@ from typing import Annotated, Any, Literal from fastapi import APIRouter, Request -from pydantic import AfterValidator, BaseModel, ConfigDict, Field +from pydantic import AfterValidator, BaseModel, ConfigDict, Field, model_validator from everos.entrypoints.api.utils import extract_request_id from everos.service import memorize @@ -111,6 +111,14 @@ class MessageItemDTO(BaseModel): tool_calls: list[ToolCallDTO] | None = None tool_call_id: str | None = None + @model_validator(mode="after") + def _tool_row_needs_call_id(self) -> MessageItemDTO: + # An orphan tool row cannot be mapped to a ConversationItem and would + # surface as a 500 from deep inside extraction; refuse it here (422). + if self.role == "tool" and not self.tool_call_id: + raise ValueError("tool_call_id is required when role is 'tool'") + return self + class MemorizeAddRequest(BaseModel): session_id: str = Field(..., min_length=1, max_length=128) diff --git a/src/everos/entrypoints/tui/demo/readme_media.py b/src/everos/entrypoints/tui/demo/readme_media.py index 846e99238..268b14be8 100644 --- a/src/everos/entrypoints/tui/demo/readme_media.py +++ b/src/everos/entrypoints/tui/demo/readme_media.py @@ -129,7 +129,7 @@ async def render_media(out_dir: Path) -> tuple[Path, Path]: frame_paths.append(frame_path) animation = out_dir / "everos-demo-tui-animation.svg" - animation.write_text(_build_animation_svg(frame_paths, plan)) + animation.write_text(_build_animation_svg(frame_paths, plan), encoding="utf-8") return screenshot, animation @@ -169,7 +169,9 @@ async def _export_frame( finally: if no_color is not None: os.environ["NO_COLOR"] = no_color - await anyio.Path(path).write_text(normalize_svg_terminal_ids(screenshot)) + await anyio.Path(path).write_text( + normalize_svg_terminal_ids(screenshot), encoding="utf-8" + ) def _export_screenshot_svg(app) -> str: @@ -229,7 +231,7 @@ def _build_animation_svg(frame_paths: Sequence[Path], plan: Sequence[FramePlan]) def _read_svg_dimensions(path: Path) -> tuple[str, str, str]: - svg_open = re.search(r"]+)>", path.read_text()) + svg_open = re.search(r"]+)>", path.read_text(encoding="utf-8")) if svg_open is None: raise ValueError(f"could not find SVG root in {path}") view_box_match = re.search(r'viewBox="([^"]+)"', svg_open.group(0)) diff --git a/src/everos/infra/ome/_dispatch/dispatcher.py b/src/everos/infra/ome/_dispatch/dispatcher.py index 525375fbf..694c69cba 100644 --- a/src/everos/infra/ome/_dispatch/dispatcher.py +++ b/src/everos/infra/ome/_dispatch/dispatcher.py @@ -29,8 +29,9 @@ from everos.infra.ome._dispatch.registry import StrategyRegistry from everos.infra.ome._stores.counter import CounterStore from everos.infra.ome.decorator import StrategyMeta -from everos.infra.ome.events import BaseEvent +from everos.infra.ome.events import BaseEvent, ManualTick from everos.infra.ome.records import CounterProgress, StrategyRouteInfo +from everos.infra.ome.triggers import Cron logger = get_logger(__name__) @@ -61,10 +62,15 @@ async def dispatch( force_enabled: Bypass the ``meta.enabled`` gate. ``applies_to`` and the counter still apply. Used by manual triggers with ``force=True``. - strategy_filter: Restrict to one strategy name regardless of - whether it subscribes to ``type(event)``. Manual triggers - use this when targeting a strategy with a caller-supplied - event. Raises ``KeyError`` if the name is not registered. + strategy_filter: Restrict to one strategy name. The strategy + must still subscribe to ``type(event)`` — a handler is never + handed an event class it did not declare (a bare + ``ManualTick`` aimed at ``Immediate(on=[AgentCaseExtracted])`` + would crash reading fields the tick does not carry). The one + exception is a ``ManualTick`` at a ``Cron`` strategy: that is + the manual run of a scheduled job, and the tick carries every + field a ``CronTick`` does. Raises ``KeyError`` if the name is + not registered. ``applies_to`` callables raised by a single strategy are caught, logged, and treated as ``False`` for that strategy alone — sibling @@ -72,7 +78,19 @@ async def dispatch( I/O) propagate. """ if strategy_filter is not None: - metas: list[StrategyMeta] = [self._registry.get(strategy_filter)] + target = self._registry.get(strategy_filter) + subscribed = {m.name for m in self._registry.lookup_by_event(type(event))} + # A manual run of a scheduled job is what the manual trigger is for + # (docs/reflection.md: reflect_episodes + force). CronTick and + # ManualTick carry the same single field, so a Cron handler cannot + # read anything the tick lacks; Idle handlers can (bucket_key, + # idle_seconds) and therefore still have to subscribe. + manual_cron = isinstance(event, ManualTick) and isinstance( + target.trigger, Cron + ) + metas: list[StrategyMeta] = ( + [target] if target.name in subscribed or manual_cron else [] + ) else: metas = list(self._registry.lookup_by_event(type(event))) out: list[tuple[StrategyMeta, str]] = [] diff --git a/src/everos/infra/ome/engine.py b/src/everos/infra/ome/engine.py index 212cef761..0c474bd3d 100644 --- a/src/everos/infra/ome/engine.py +++ b/src/everos/infra/ome/engine.py @@ -618,7 +618,9 @@ async def trigger_manual( ) -> tuple[BaseEvent, list[tuple[StrategyMeta, str]]]: """Manually trigger one strategy. - - ``event=None`` → engine self-emits ``ManualTick(strategy_name=name)`` + - ``event=None`` → engine self-emits ``ManualTick(strategy_name=name)``; + a strategy that does not list ``ManualTick`` in its trigger is not + dispatched (``routes`` comes back empty) - ``force=True`` → bypass the ``enabled`` gate (``applies_to`` and ``Counter`` still apply) diff --git a/src/everos/infra/persistence/lancedb/__init__.py b/src/everos/infra/persistence/lancedb/__init__.py index 78ea873ed..5f4d0ba31 100644 --- a/src/everos/infra/persistence/lancedb/__init__.py +++ b/src/everos/infra/persistence/lancedb/__init__.py @@ -118,7 +118,11 @@ async def migrate_fts_indexes() -> None: async with memory_root_lock(memory_root): marker = memory_root.lancedb_dir / ".fts_index_version" try: - current = int(marker.read_text().strip()) if marker.exists() else 0 + current = ( + int(marker.read_text(encoding="utf-8").strip()) + if marker.exists() + else 0 + ) except (ValueError, OSError): current = 0 if current >= _FTS_INDEX_SCHEMA_VERSION: @@ -140,7 +144,7 @@ async def migrate_fts_indexes() -> None: # so compaction no longer decodes a position List. with contextlib.suppress(Exception): await table.optimize(cleanup_older_than=dt.timedelta(seconds=0)) - marker.write_text(str(_FTS_INDEX_SCHEMA_VERSION)) + marker.write_text(str(_FTS_INDEX_SCHEMA_VERSION), encoding="utf-8") logger.info("fts_index_migration_done", version=_FTS_INDEX_SCHEMA_VERSION) @@ -219,7 +223,11 @@ async def migrate_table_schemas() -> None: async with memory_root_lock(memory_root): marker = memory_root.lancedb_dir / ".table_schema_version" try: - current = int(marker.read_text().strip()) if marker.exists() else 0 + current = ( + int(marker.read_text(encoding="utf-8").strip()) + if marker.exists() + else 0 + ) except (ValueError, OSError): current = 0 if current >= _TABLE_SCHEMA_VERSION: @@ -266,7 +274,7 @@ async def migrate_table_schemas() -> None: marker.parent.mkdir(parents=True, exist_ok=True) try: - marker.write_text(str(_TABLE_SCHEMA_VERSION)) + marker.write_text(str(_TABLE_SCHEMA_VERSION), encoding="utf-8") except OSError: logger.error("table_schema_migration_marker_write_failed", path=str(marker)) raise diff --git a/src/everos/memory/cascade/orchestrator.py b/src/everos/memory/cascade/orchestrator.py index 794fec2d8..9a73b21ea 100644 --- a/src/everos/memory/cascade/orchestrator.py +++ b/src/everos/memory/cascade/orchestrator.py @@ -108,6 +108,7 @@ def from_settings(cls) -> CascadeConfig: optimize_rebuild_interval_seconds=( cascade.optimize_rebuild_interval_seconds ), + scan_interval_seconds=cascade.scan_interval_seconds, ) diff --git a/src/everos/memory/cascade/scanner.py b/src/everos/memory/cascade/scanner.py index 9a5811365..7e1d1cf98 100644 --- a/src/everos/memory/cascade/scanner.py +++ b/src/everos/memory/cascade/scanner.py @@ -7,7 +7,7 @@ - WSL2 / network mounts where fsevents don't propagate. The scanner closes those gaps by walking the memory root every -``scan_interval`` seconds (default 30s, configurable later), matching +``scan_interval`` seconds (default 30s, ``[cascade] scan_interval_seconds``), matching paths against the kind registry, reading prior state, and running the pure :func:`reconcile` function to emit the upsert plan. diff --git a/src/everos/memory/cascade/watcher.py b/src/everos/memory/cascade/watcher.py index e0244bfa7..c8c79d988 100644 --- a/src/everos/memory/cascade/watcher.py +++ b/src/everos/memory/cascade/watcher.py @@ -131,6 +131,14 @@ def _enqueue(self, raw_path: str, change_type: str) -> None: spec = match_kind(rel) if spec is None: return + # A late ``added`` / ``modified`` for a path that is already gone. + # FSEvents coalesces and reorders: a create followed by an unlink + # inside its latency window can deliver the modified leg last, and + # letting it through would overwrite the ``deleted`` row and + # resurrect a file that no longer exists (until the scanner's next + # sweep notices). Disk is the truth; record what is actually there. + if change_type != "deleted" and not Path(raw_path).exists(): + change_type = "deleted" mtime = _safe_mtime(raw_path) asyncio.run_coroutine_threadsafe( self._serialised(_enqueue_async(spec, rel, change_type, mtime)), diff --git a/src/everos/memory/extract/parser/mapping.py b/src/everos/memory/extract/parser/mapping.py index 204588c69..78106f750 100644 --- a/src/everos/memory/extract/parser/mapping.py +++ b/src/everos/memory/extract/parser/mapping.py @@ -15,7 +15,8 @@ import base64 from pathlib import Path from typing import Any -from urllib.parse import unquote, urlparse +from urllib.parse import urlparse +from urllib.request import url2pathname import anyio from everalgo.types import RawFile @@ -62,6 +63,10 @@ def _is_file_uri(uri: str) -> bool: def _resolve_file_uri(uri: str) -> Path: """Parse a ``file://`` uri into a canonical local path (symlinks resolved). + ``url2pathname`` does the percent-decoding and, on Windows, turns the + URI's leading-slash drive form (``/C:/x``) into a real path — a plain + ``unquote`` leaves ``/C:/x``, which is not the same file. + Raises ``ValueError`` for a remote host component or a path that does not exist (``resolve(strict=True)``). """ @@ -69,7 +74,7 @@ def _resolve_file_uri(uri: str) -> Path: if parsed.netloc and parsed.netloc not in ("", "localhost"): raise ValueError(f"file uri with remote host not supported: {parsed.netloc!r}") try: - return Path(unquote(parsed.path)).expanduser().resolve(strict=True) + return Path(url2pathname(parsed.path)).expanduser().resolve(strict=True) except OSError as exc: raise ValueError(f"cannot resolve file uri {uri!r}: {exc}") from exc diff --git a/tests/conftest.py b/tests/conftest.py index b373d65ff..e41539cad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,16 @@ from __future__ import annotations +# First import in the test process, on purpose. everos/__init__.py registers +# the MSVC runtime DLL directory with the Windows loader, and that only helps +# extensions imported *after* it -- sqlalchemy imports greenlet the moment it +# is imported, and a test module's first line is often `from sqlmodel import`. +# Any host application that imports sqlalchemy before everos has the same +# problem; the package-init hook cannot reach it. Until a startup .pth ships +# the registration for every interpreter in the environment, this line keeps +# the suite meaningful on a Windows machine without the VC++ redistributable. +import everos # noqa: F401 isort: skip + import json from collections.abc import Iterator from pathlib import Path diff --git a/tests/e2e/test_multimodal_add_e2e.py b/tests/e2e/test_multimodal_add_e2e.py index 31134635f..20b85a18e 100644 --- a/tests/e2e/test_multimodal_add_e2e.py +++ b/tests/e2e/test_multimodal_add_e2e.py @@ -129,7 +129,7 @@ async def test_add_html_file_uri_parsed_into_buffer( "sender_id": "alice", "role": "user", "timestamp": 1780304400000, - "content": [{"type": "html", "uri": f"file://{doc}"}], + "content": [{"type": "html", "uri": doc.as_uri()}], } ], }, diff --git a/tests/e2e/test_reflection_e2e.py b/tests/e2e/test_reflection_e2e.py index 882483b57..03502b8d8 100644 --- a/tests/e2e/test_reflection_e2e.py +++ b/tests/e2e/test_reflection_e2e.py @@ -10,6 +10,7 @@ from __future__ import annotations import argparse +import importlib import json import logging import sys @@ -17,22 +18,30 @@ from pathlib import Path from typing import Any -# benchmarks/run.py is the benchmark runner; add repo root to sys.path so -# the benchmarks package is importable from any working directory. -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from benchmarks.run import ( - ANSWER_PROMPT, - JUDGE_SYSTEM_PROMPT, - JUDGE_USER_PROMPT, - EverosClient, - LLMClientPool, - _build_context, - _extract_final_answer, - _extract_json, - _parse_session_timestamp, - print_section, -) +# benchmarks/run.py is a script, not a package module: it imports its +# siblings bare (``import adapters``), which only resolves with +# ``benchmarks/`` itself on sys.path. Importing it as ``benchmarks.run`` +# therefore fails at collection and, by pytest's default, aborts the whole +# run before a single test executes. The unit tests that use the runner do +# this same dance (``test_benchmark_cli_portability.py``); mirror it. +_BENCH = Path(__file__).resolve().parents[2] / "benchmarks" +if str(_BENCH) not in sys.path: + sys.path.insert(0, str(_BENCH)) +_run = importlib.import_module("run") +# The prompts moved from run.py into the per-benchmark adapters when the +# runner was generalised (#425); this suite is LoCoMo conv_0, so LoCoMo's. +_locomo = importlib.import_module("adapters.locomo") +_LOCOMO_CONFIG = importlib.import_module("config").BenchmarkConfig.from_toml("locomo") +ANSWER_PROMPT = _locomo.ANSWER_PROMPT +JUDGE_SYSTEM_PROMPT = _locomo.JUDGE_SYSTEM_PROMPT +JUDGE_USER_PROMPT = _locomo.JUDGE_USER_PROMPT +EverosClient = _run.EverosClient +LLMClientPool = _run.LLMClientPool +_build_context = _run._build_context +_extract_final_answer = _run._extract_final_answer +_extract_json = _run._extract_json +_parse_session_timestamp = _run._parse_session_timestamp +print_section = _run.print_section logger = logging.getLogger(__name__) @@ -292,8 +301,10 @@ def answer_and_judge( search_data.get("profiles", []), speaker_a, speaker_b, + _LOCOMO_CONFIG, ) - prompt = ANSWER_PROMPT.format(context=context, question=query) + # No session date is known here; the runner passes "" in that case too. + prompt = ANSWER_PROMPT.format(context=context, current_date_line="", question=query) try: resp = llm_client.chat.completions.create( model=llm_model, diff --git a/tests/integration/test_cli/test_backfill_flags.py b/tests/integration/test_cli/test_backfill_flags.py index 4a0abdd7c..71c88f18f 100644 --- a/tests/integration/test_cli/test_backfill_flags.py +++ b/tests/integration/test_cli/test_backfill_flags.py @@ -38,6 +38,7 @@ import hashlib import os import signal +import sys import threading import time from collections.abc import AsyncIterator @@ -237,6 +238,11 @@ async def _raise_interrupt(*_args: object, **_kwargs: object) -> None: @pytest.mark.slow +@pytest.mark.skipif( + sys.platform == "win32", + reason="os.kill(pid, SIGINT) is TerminateProcess on Windows: it kills the " + "pytest process itself (exit code 2) instead of delivering a Ctrl-C", +) def test_real_sigint_during_phase_await_returns_130_with_resume_hint( backfill_runtime: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/test_benchmark_cli_portability.py b/tests/unit/test_benchmark_cli_portability.py index 096678fda..4738f45ee 100644 --- a/tests/unit/test_benchmark_cli_portability.py +++ b/tests/unit/test_benchmark_cli_portability.py @@ -14,6 +14,7 @@ from __future__ import annotations import importlib +import re import sys from pathlib import Path from typing import Any @@ -67,7 +68,7 @@ def test_a_built_in_name_still_resolves_under_configs(name: str) -> None: def test_the_error_names_the_path_it_actually_opened(tmp_path: Path) -> None: """Otherwise a mis-resolved name is indistinguishable from a missing file.""" missing = tmp_path / "absent.toml" - with pytest.raises(FileNotFoundError, match=str(missing)): + with pytest.raises(FileNotFoundError, match=re.escape(str(missing))): BenchmarkConfig.from_toml(str(missing)) with pytest.raises(FileNotFoundError, match=r"nosuchbenchmark\.toml"): diff --git a/tests/unit/test_benchmark_profile_trace_config.py b/tests/unit/test_benchmark_profile_trace_config.py index 35fefc773..789482bdb 100644 --- a/tests/unit/test_benchmark_profile_trace_config.py +++ b/tests/unit/test_benchmark_profile_trace_config.py @@ -32,7 +32,9 @@ def _config(name: str) -> BenchmarkConfig: - raw = tomllib.loads((_BENCH / "configs" / f"{name}.toml").read_text()) + raw = tomllib.loads( + (_BENCH / "configs" / f"{name}.toml").read_text(encoding="utf-8") + ) flat = {k: v for k, v in raw.items() if not isinstance(v, dict)} for section in ("answer", "judge"): for k, v in (raw.get(section) or {}).items(): @@ -56,7 +58,7 @@ def _config(name: str) -> BenchmarkConfig: @pytest.mark.parametrize("name", _ARMS) def test_every_benchmark_declares_both_knobs(name: str) -> None: """Explicit in the toml, so the recorded run says what it did.""" - text = (_BENCH / "configs" / f"{name}.toml").read_text() + text = (_BENCH / "configs" / f"{name}.toml").read_text(encoding="utf-8") assert "include_profile" in text, f"{name} does not declare include_profile" assert "\ntrace = " in text, f"{name} does not declare trace" cfg = _config(name) @@ -106,7 +108,9 @@ def test_the_longmemeval_baseline_still_names_the_reference_models() -> None: in its own file for the same reason -- which is what `longmemeval_qwen38.toml` was until it was deleted, having served its one operator run. """ - base = tomllib.loads((_BENCH / "configs" / "longmemeval.toml").read_text()) + base = tomllib.loads( + (_BENCH / "configs" / "longmemeval.toml").read_text(encoding="utf-8") + ) assert base["backbone_model"] == "deepseek/deepseek-v4-pro-0813" # The decider is named by BENCH_DECIDER_MODEL rather than hardcoded: shipping a # model the reader does not serve, with an endpoint that may be unset, is the @@ -132,7 +136,9 @@ def test_every_benchmark_disables_the_inotify_watcher(name: str) -> None: not correctness -- but ``EVEROS_DISABLE_CASCADE`` would take the worker with it and md would never reach LanceDB at all. """ - raw = tomllib.loads((_BENCH / "configs" / f"{name}.toml").read_text()) + raw = tomllib.loads( + (_BENCH / "configs" / f"{name}.toml").read_text(encoding="utf-8") + ) assert raw["retrieval_env"]["EVEROS_DISABLE_CASCADE_WATCHER"] == "1" # The worker must survive: this is an ingesting run. assert "EVEROS_DISABLE_CASCADE" not in raw["retrieval_env"] @@ -147,7 +153,7 @@ def test_smoke_does_not_discard_an_explicit_conv_list() -> None: had chosen, not the ones it was given -- so the only way to catch it was to count graded rows against what you expected. """ - src = (_BENCH / "run.py").read_text() + src = (_BENCH / "run.py").read_text(encoding="utf-8") i = src.index("if args.smoke and not _conv_given:") assert "_conv_given = args.conv is not None" in src[:i] # The guard has to sit on the assignment itself, not merely exist somewhere. diff --git a/tests/unit/test_core/test_persistence/test_lancedb/test_platform_filesystem.py b/tests/unit/test_core/test_persistence/test_lancedb/test_platform_filesystem.py new file mode 100644 index 000000000..802e36ad2 --- /dev/null +++ b/tests/unit/test_core/test_persistence/test_lancedb/test_platform_filesystem.py @@ -0,0 +1,195 @@ +"""LanceDB against filesystem semantics that differ by platform. + +Every test here passes trivially on POSIX and exists to be run by the Windows +CI job, where NTFS and Win32 change the rules a local store leans on: a file +with an open handle cannot be deleted or renamed, ``rmdir`` on a directory +something else holds is a sharing violation, and a path past 260 characters +needs an opt-in. None of these are Lance bugs. They are the reasons a store +that works on a Mac can fail the first time a Windows user deletes, moves or +deeply nests their memory root. +""" + +from __future__ import annotations + +import datetime as dt +import gc +import inspect +import os +import shutil +import time +from pathlib import Path +from typing import Any, ClassVar + +import pytest + +from everos.config import LanceDBSettings +from everos.core.persistence import ( + BaseLanceTable, + MemoryRoot, + Vector, + open_lancedb_connection, +) +from everos.core.persistence.lancedb import LanceDailyLogRepoBase, LanceRepoBase +from everos.core.persistence.lancedb.repository import ( + _HUSK_MIN_AGE_SECONDS, + _remove_empty_index_dirs, +) + + +class _Probe(BaseLanceTable): + TABLE_NAME: ClassVar[str] = "_probe" + + id: str + owner_id: str + app_id: str = "default" + project_id: str = "default" + entry_id: str + session_id: str = "s" + parent_type: str = "memcell" + parent_id: str = "mc" + md_path: str = "users/u/notes/x.md" + text: str = "x" + vector: Vector(4) # type: ignore[valid-type] + + +class _ProbeRepo(LanceDailyLogRepoBase[_Probe]): + schema = _Probe + + +def _rows(n: int, *, prefix: str = "e") -> list[_Probe]: + return [ + _Probe( + id=f"u_{prefix}{i}", + owner_id="u", + entry_id=f"{prefix}{i}", + vector=[1.0, 0, 0, 0], + ) + for i in range(n) + ] + + +async def _close(obj: Any) -> None: + """``AsyncTable.close`` / ``AsyncConnection.close`` are sync in 0.34; stay + correct if a later release makes them awaitable.""" + r = obj.close() + if inspect.isawaitable(r): + await r + + +@pytest.fixture(autouse=True) +def _reset_write_locks() -> None: + LanceRepoBase._reset_locks_for_tests() + + +# ── sharing violations ────────────────────────────────────────────────────── + + +def test_husk_sweep_skips_a_refused_rmdir_and_keeps_going( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """On Windows an ``rmdir`` of a directory another process holds open is + ``PermissionError``, not ``ENOTEMPTY``. One refused husk must not abort + the sweep of the others -- the sweep is best-effort by contract.""" + indices = tmp_path / "_indices" + husks = [indices / n for n in ("aaa", "bbb", "ccc")] + for h in husks: + h.mkdir(parents=True) + old = time.time() - _HUSK_MIN_AGE_SECONDS * 2 + os.utime(h, (old, old)) + + real_rmdir = Path.rmdir + + def refusing_rmdir(self: Path) -> None: + if self.name == "bbb": + raise PermissionError(32, "The process cannot access the file", str(self)) + real_rmdir(self) + + monkeypatch.setattr(Path, "rmdir", refusing_rmdir) + + removed = _remove_empty_index_dirs( + str(tmp_path), live_uuids=frozenset(), min_age_seconds=1.0 + ) + + assert removed == 2 + assert not husks[0].exists() and not husks[2].exists() + assert husks[1].exists(), "the refused one is left for the next sweep" + + +# ── handle release ────────────────────────────────────────────────────────── + + +async def test_table_files_are_releasable_after_close(tmp_path: Path) -> None: + """Deleting or moving the memory root is an ordinary user action. + + POSIX lets you unlink an open file; Windows does not. If Lance keeps a + handle or mapping alive past ``close()``, ``rmtree`` fails with + ``PermissionError`` on the first Windows user who tries -- and pytest's + own tmp_path cleanup would have hidden it, since that runs at the *next* + session start. + """ + mr = MemoryRoot(tmp_path / "root") + mr.ensure() + conn = await open_lancedb_connection(mr.lancedb_dir, LanceDBSettings()) + table = await conn.create_table(_Probe.TABLE_NAME, schema=_Probe) + await table.add([r.model_dump() for r in _rows(2)]) + assert await table.count_rows() == 2 + + await _close(table) + await _close(conn) + del table, conn + gc.collect() + + shutil.rmtree(mr.lancedb_dir) + assert not mr.lancedb_dir.exists() + + +async def test_prune_completes_while_another_handle_reads_the_table( + tmp_path: Path, +) -> None: + """A second connection holds the table open while prune reclaims old + versions. On Windows the reclaimed files may be the ones the reader has + mapped; the store must still finish and both handles must agree on the + rows afterwards.""" + mr = MemoryRoot(tmp_path) + mr.ensure() + writer_conn = await open_lancedb_connection(mr.lancedb_dir, LanceDBSettings()) + table = await writer_conn.create_table(_Probe.TABLE_NAME, schema=_Probe) + repo = _ProbeRepo(table=table) + await repo.add(_rows(3, prefix="a")) + await repo.add(_rows(3, prefix="b")) # a second version to reclaim + + reader_conn = await open_lancedb_connection(mr.lancedb_dir, LanceDBSettings()) + reader = await reader_conn.open_table(_Probe.TABLE_NAME) + assert await reader.count_rows() == 6 + + await repo.prune(dt.timedelta(seconds=0)) + + assert await repo.count() == 6 + assert await reader.count_rows() == 6 + await _close(reader) + await _close(reader_conn) + await _close(table) + await _close(writer_conn) + + +# ── path length ───────────────────────────────────────────────────────────── + + +async def test_deep_memory_root_still_stores_and_counts(tmp_path: Path) -> None: + """Windows caps paths at 260 characters unless long paths are enabled. + + The memory root is user-chosen and Lance nests ``.lance/data/`` + and ``_indices//`` under it, so a deep root can cross the cap while + looking perfectly ordinary. A red here on Windows means the store needs + ``LongPathsEnabled`` and the install guide has to say so. + """ + deep = tmp_path.joinpath(*(["x" * 40] * 4)) # +164 chars before Lance adds its own + mr = MemoryRoot(deep) + mr.ensure() + conn = await open_lancedb_connection(mr.lancedb_dir, LanceDBSettings()) + table = await conn.create_table(_Probe.TABLE_NAME, schema=_Probe) + await table.add([r.model_dump() for r in _rows(3)]) + assert await table.count_rows() == 3 + assert len(str(mr.lancedb_dir)) > 200, "probe must actually be deep" + await _close(table) + await _close(conn) diff --git a/tests/unit/test_core/test_persistence/test_locking.py b/tests/unit/test_core/test_persistence/test_locking.py index bd4ea560c..8683afd7f 100644 --- a/tests/unit/test_core/test_persistence/test_locking.py +++ b/tests/unit/test_core/test_persistence/test_locking.py @@ -27,6 +27,12 @@ async def test_lock_acquire_release_acquire(tmp_path: Path) -> None: pass +# A spawned child re-imports this module and, through everos.core.persistence, +# lancedb: 1-2 s on macOS, 15 s+ on Windows with Defender scanning each file. +# Only readiness is bounded by this; the timing assertions start after ready.set(). +_SPAWN_TIMEOUT_S = 60.0 + + def _hold_lock(memory_root_path: str, ready: object, release: object) -> None: """Subprocess helper: acquire blocking lock, signal, wait, release. @@ -53,13 +59,13 @@ async def test_nonblocking_raises_when_held_by_other_process(tmp_path: Path) -> proc = ctx.Process(target=_hold_lock, args=(str(mr.root), ready, release)) proc.start() try: - assert ready.wait(timeout=5), "subprocess failed to acquire lock" + assert ready.wait(timeout=_SPAWN_TIMEOUT_S), "subprocess failed to acquire lock" with pytest.raises(LockError): async with memory_root_lock(mr, blocking=False): pass finally: release.set() - proc.join(timeout=5) + proc.join(timeout=_SPAWN_TIMEOUT_S) if proc.is_alive(): proc.terminate() @@ -73,7 +79,7 @@ async def test_blocking_waits_for_release(tmp_path: Path) -> None: proc = ctx.Process(target=_hold_lock, args=(str(mr.root), ready, release)) proc.start() try: - assert ready.wait(timeout=5) + assert ready.wait(timeout=_SPAWN_TIMEOUT_S) # Schedule the subprocess to release shortly; main process should # acquire the lock after that. release_started = time.monotonic() @@ -91,7 +97,7 @@ def release_after_short_delay() -> None: assert elapsed >= 0.1 finally: release.set() - proc.join(timeout=5) + proc.join(timeout=_SPAWN_TIMEOUT_S) if proc.is_alive(): proc.terminate() @@ -128,7 +134,7 @@ def rec(event: str, **_kw) -> None: # type: ignore[no-untyped-def] proc = ctx.Process(target=_hold_lock, args=(str(mr.root), ready, release)) proc.start() try: - assert ready.wait(timeout=5) + assert ready.wait(timeout=_SPAWN_TIMEOUT_S) started = time.monotonic() with pytest.raises(LockError, match="timed out"): async with memory_root_lock(mr, timeout_seconds=0.2): @@ -141,7 +147,7 @@ def rec(event: str, **_kw) -> None: # type: ignore[no-untyped-def] ) finally: release.set() - proc.join(timeout=5) + proc.join(timeout=_SPAWN_TIMEOUT_S) if proc.is_alive(): proc.terminate() @@ -180,7 +186,7 @@ def rec(event: str, **_kw) -> None: # type: ignore[no-untyped-def] proc = ctx.Process(target=_hold_lock, args=(str(mr.root), ready, release)) proc.start() try: - assert ready.wait(timeout=5) + assert ready.wait(timeout=_SPAWN_TIMEOUT_S) threading.Timer(0.2, release.set).start() async with memory_root_lock(mr, timeout_seconds=5.0): pass @@ -190,7 +196,7 @@ def rec(event: str, **_kw) -> None: # type: ignore[no-untyped-def] ] finally: release.set() - proc.join(timeout=5) + proc.join(timeout=_SPAWN_TIMEOUT_S) if proc.is_alive(): proc.terminate() diff --git a/tests/unit/test_core/test_persistence/test_markdown/test_writer_replace_retry.py b/tests/unit/test_core/test_persistence/test_markdown/test_writer_replace_retry.py new file mode 100644 index 000000000..010045f87 --- /dev/null +++ b/tests/unit/test_core/test_persistence/test_markdown/test_writer_replace_retry.py @@ -0,0 +1,89 @@ +"""The staging-to-target swap must outlast a Windows sharing violation. + +On Windows, ``os.replace`` onto a file another process has open raises +``PermissionError``; on POSIX it never does. These tests fake ``os.replace`` +so the behaviour is pinned on every platform: a transient refusal is retried +and the write lands, a persistent one propagates after the budget with the +staging file cleaned up, and any other error is not retried at all. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path + +import pytest + +from everos.core.persistence import MemoryRoot +from everos.core.persistence.markdown import writer as writer_mod +from everos.core.persistence.markdown.writer import MarkdownWriter + + +class _Refusing: + """Stand-in for a Windows loader that refuses the first ``n`` replaces.""" + + def __init__(self, refuse: int, error: type[OSError] = PermissionError) -> None: + self.refuse = refuse + self.error = error + self.calls = 0 + self._real = os.replace + + def __call__(self, src: str | Path, dst: str | Path) -> None: + self.calls += 1 + if self.calls <= self.refuse: + raise self.error(5, "The process cannot access the file", str(dst)) + self._real(src, dst) + + +@pytest.fixture +def no_sleep(monkeypatch: pytest.MonkeyPatch) -> list[float]: + slept: list[float] = [] + monkeypatch.setattr(time, "sleep", slept.append) + return slept + + +async def test_transient_refusal_is_retried_and_the_write_lands( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, no_sleep: list[float] +) -> None: + refusing = _Refusing(refuse=2) + monkeypatch.setattr(os, "replace", refusing) + target = tmp_path / "users" / "u1" / "note.md" + + await MarkdownWriter(MemoryRoot(tmp_path)).write(target, "survived") + + assert target.read_text(encoding="utf-8") == "survived" + assert refusing.calls == 3 + assert no_sleep == [0.02, 0.04], "exponential backoff between the two refusals" + assert not list(target.parent.glob(".*.tmp.*")), "no staging file left behind" + + +async def test_persistent_refusal_propagates_after_the_budget( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, no_sleep: list[float] +) -> None: + refusing = _Refusing(refuse=10**6) + monkeypatch.setattr(os, "replace", refusing) + target = tmp_path / "users" / "u1" / "note.md" + + with pytest.raises(PermissionError): + await MarkdownWriter(MemoryRoot(tmp_path)).write(target, "never lands") + + assert refusing.calls == writer_mod._REPLACE_ATTEMPTS + assert len(no_sleep) == writer_mod._REPLACE_ATTEMPTS - 1 + assert not target.exists() + assert not list(target.parent.glob(".*.tmp.*")), "staging file cleaned on failure" + + +async def test_other_errors_are_not_retried( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, no_sleep: list[float] +) -> None: + """Retrying would only delay a real failure (disk full, bad path).""" + refusing = _Refusing(refuse=10**6, error=FileNotFoundError) + monkeypatch.setattr(os, "replace", refusing) + target = tmp_path / "users" / "u1" / "note.md" + + with pytest.raises(FileNotFoundError): + await MarkdownWriter(MemoryRoot(tmp_path)).write(target, "x") + + assert refusing.calls == 1 + assert no_sleep == [] diff --git a/tests/unit/test_core/test_persistence/test_memory_root.py b/tests/unit/test_core/test_persistence/test_memory_root.py index 6da6ccd9a..1fd52c2dc 100644 --- a/tests/unit/test_core/test_persistence/test_memory_root.py +++ b/tests/unit/test_core/test_persistence/test_memory_root.py @@ -135,5 +135,7 @@ def test_frozen_dataclass_hashable(tmp_path: Path) -> None: def test_user_expansion(tmp_path: Path, monkeypatch) -> None: monkeypatch.setenv("HOME", str(tmp_path)) + # Path.expanduser() consults USERPROFILE on Windows and HOME on POSIX. + monkeypatch.setenv("USERPROFILE", str(tmp_path)) mr = MemoryRoot("~/custom") assert mr.root == (tmp_path / "custom").resolve() diff --git a/tests/unit/test_entrypoints/test_api/test_routes/test_memorize_route_validation.py b/tests/unit/test_entrypoints/test_api/test_routes/test_memorize_route_validation.py index 826def4d8..0cd80f019 100644 --- a/tests/unit/test_entrypoints/test_api/test_routes/test_memorize_route_validation.py +++ b/tests/unit/test_entrypoints/test_api/test_routes/test_memorize_route_validation.py @@ -10,15 +10,36 @@ from __future__ import annotations +from collections.abc import AsyncIterator +from pathlib import Path + import pytest +from httpx import ASGITransport, AsyncClient from pydantic import ValidationError +from everos.config import load_settings +from everos.entrypoints.api.app import create_app from everos.entrypoints.api.routes.memorize import ( MemorizeAddRequest, MessageItemDTO, ) +@pytest.fixture +async def client( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[AsyncClient]: + """FastAPI app with no lifespan; nothing past DTO validation is reached.""" + monkeypatch.setenv("EVEROS_ROOT", str(tmp_path)) + load_settings.cache_clear() + app = create_app(lifespan_providers=[]) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: + yield c + load_settings.cache_clear() + + def _message(sender_id: str) -> MessageItemDTO: return MessageItemDTO( sender_id=sender_id, @@ -62,6 +83,48 @@ def test_message_item_accepts_path_safe_sender_id(good_sender_id: str) -> None: assert _message(good_sender_id).sender_id == good_sender_id +def test_message_item_rejects_tool_role_without_call_id() -> None: + # An orphan tool row used to travel to ``_boundary`` and 500 from + # inside extraction; it is refused at the DTO now. + with pytest.raises(ValidationError, match="tool_call_id"): + MessageItemDTO( + sender_id="agent", + role="tool", + timestamp=1_700_000_000_000, + content="x", + ) + + +def test_message_item_accepts_tool_role_with_call_id() -> None: + m = MessageItemDTO( + sender_id="agent", + role="tool", + timestamp=1_700_000_000_000, + content="x", + tool_call_id="call_1", + ) + assert m.tool_call_id == "call_1" + + +async def test_add_orphan_tool_row_is_422_not_500(client: AsyncClient) -> None: + resp = await client.post( + "/api/v1/memory/add", + json={ + "session_id": "s1", + "messages": [ + { + "sender_id": "agent", + "role": "tool", + "timestamp": 1_700_000_000_000, + "content": "x", + } + ], + }, + ) + assert resp.status_code == 422 + assert "tool_call_id" in resp.text + + def test_add_request_rejects_traversal_sender_id_in_messages() -> None: # The guard fires through the nested message list, not just on a bare DTO. with pytest.raises(ValidationError): diff --git a/tests/unit/test_entrypoints/test_api/test_routes/test_ome.py b/tests/unit/test_entrypoints/test_api/test_routes/test_ome.py index 0ffe9994d..1bc0fef43 100644 --- a/tests/unit/test_entrypoints/test_api/test_routes/test_ome.py +++ b/tests/unit/test_entrypoints/test_api/test_routes/test_ome.py @@ -27,8 +27,8 @@ from everos.infra.ome.context import StrategyContext from everos.infra.ome.decorator import offline_strategy from everos.infra.ome.engine import OfflineEngine -from everos.infra.ome.events import ManualTick -from everos.infra.ome.triggers import Immediate +from everos.infra.ome.events import CronTick, ManualTick +from everos.infra.ome.triggers import Cron, Immediate async def _client_for( @@ -66,6 +66,29 @@ async def _s(event: ManualTick, ctx: StrategyContext) -> None: await engine.stop() +@pytest.fixture +async def cron_engine(tmp_path: Path) -> AsyncIterator[OfflineEngine]: + """Engine with one Cron strategy — the ``reflect_episodes`` shape.""" + + @offline_strategy( + name="weekly_job", + trigger=Cron(expr="0 2 * * 1"), + emits=[], + ) + async def _s(event: CronTick, ctx: StrategyContext) -> None: + return None + + engine = OfflineEngine( + config=OMEConfig(jobstore_path=tmp_path / "ome.db", config_watch=False) + ) + engine.register(_s) + await engine.start() + try: + yield engine + finally: + await engine.stop() + + @pytest.fixture async def always_fails_engine(tmp_path: Path) -> AsyncIterator[OfflineEngine]: """Engine with a strategy that raises unconditionally. @@ -129,3 +152,19 @@ async def test_trigger_returns_runs_including_dead_letter( assert len(body["runs"]) == 1 assert body["runs"][0]["status"] == "dead_letter" assert body["runs"][0]["error"] + + +@pytest.mark.asyncio +async def test_trigger_runs_a_cron_strategy_manually( + cron_engine: OfflineEngine, monkeypatch: pytest.MonkeyPatch +) -> None: + """A manual trigger is how a scheduled job is run on demand + (docs/reflection.md); it must dispatch, not report ``not_dispatched``.""" + async with await _client_for(cron_engine, monkeypatch) as client: + resp = await client.post( + "/api/v1/ome/trigger", json={"name": "weekly_job", "force": True} + ) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["dispatched"] == 1 diff --git a/tests/unit/test_entrypoints/test_cli/test_init_command.py b/tests/unit/test_entrypoints/test_cli/test_init_command.py index f38be73b0..da2c9e040 100644 --- a/tests/unit/test_entrypoints/test_cli/test_init_command.py +++ b/tests/unit/test_entrypoints/test_cli/test_init_command.py @@ -69,7 +69,7 @@ def test_refuses_overwrite_without_force(runner: CliRunner, tmp_path: Path) -> N result = runner.invoke(app, ["init", "--root", str(target)]) assert result.exit_code == 1 # Original content must be preserved. - assert (target / "everos.toml").read_text() == "# user-edited\n" + assert (target / "everos.toml").read_text(encoding="utf-8") == "# user-edited\n" def test_force_overwrites(runner: CliRunner, tmp_path: Path) -> None: @@ -80,7 +80,7 @@ def test_force_overwrites(runner: CliRunner, tmp_path: Path) -> None: result = runner.invoke(app, ["init", "--root", str(target), "--force"]) assert result.exit_code == 0 # Content is now the shipped template, not the user edit. - assert (target / "everos.toml").read_text() != "# user-edited\n" + assert (target / "everos.toml").read_text(encoding="utf-8") != "# user-edited\n" def test_print_writes_stdout_not_disk(runner: CliRunner, tmp_path: Path) -> None: @@ -101,7 +101,7 @@ def test_partial_overwrite_skips_existing(runner: CliRunner, tmp_path: Path) -> result = runner.invoke(app, ["init", "--root", str(target)]) assert result.exit_code == 0 # everos.toml preserved, ome.toml created. - assert (target / "everos.toml").read_text() == "# user-edited\n" + assert (target / "everos.toml").read_text(encoding="utf-8") == "# user-edited\n" assert (target / "ome.toml").is_file() diff --git a/tests/unit/test_entrypoints/test_tui/test_demo_app.py b/tests/unit/test_entrypoints/test_tui/test_demo_app.py index 77532a60f..bafe27e82 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_app.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_app.py @@ -394,6 +394,10 @@ async def test_conversation_panel_scrolls_when_log_overflows() -> None: for i in range(8): app._record_line("you", f"memory {i}") app._record_line("everos", f"a long recalled answer for round {i}") + # Each _record_line defers its scroll_end past the layout refresh that + # Static.update() triggers; one pause is not always enough to drain 16 + # of them, and a late one landing after scroll_home re-pins the bottom. + await pilot.pause() await pilot.pause() panel = app.query_one("#conversation", VerticalScroll) @@ -401,6 +405,7 @@ async def test_conversation_panel_scrolls_when_log_overflows() -> None: assert panel.scroll_y == panel.max_scroll_y # newest line auto-pinned panel.scroll_home(animate=False) await pilot.pause() + await pilot.pause() assert panel.scroll_y == 0 # user can scroll back to the start diff --git a/tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py b/tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py index b258f91a1..df7650f02 100644 --- a/tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py +++ b/tests/unit/test_entrypoints/test_tui/test_demo_readme_media.py @@ -102,7 +102,7 @@ def fast_mount(self: DotSphereWidget) -> None: await _export_frame(path, FramePlan(state="booting", phase=0.0)) - svg = html.unescape(path.read_text()).replace("\xa0", " ") + svg = html.unescape(path.read_text(encoding="utf-8")).replace("\xa0", " ") assert "working..." in svg assert "ingesting conversation dots" not in svg @@ -112,6 +112,6 @@ async def test_export_frame_preserves_poster_palette(tmp_path) -> None: await _export_frame(path, FramePlan(state="remembered", phase=0.5)) - svg = path.read_text().lower() + svg = path.read_text(encoding="utf-8").lower() assert "#f9b91c" in svg assert "#f5eddc" in svg diff --git a/tests/unit/test_infra/test_ome/test_dispatcher.py b/tests/unit/test_infra/test_ome/test_dispatcher.py index af1711d7b..0f2ea37a9 100644 --- a/tests/unit/test_infra/test_ome/test_dispatcher.py +++ b/tests/unit/test_infra/test_ome/test_dispatcher.py @@ -11,7 +11,7 @@ from everos.infra.ome._stores.storage import OMEStorage from everos.infra.ome.context import StrategyContext from everos.infra.ome.decorator import offline_strategy -from everos.infra.ome.events import BaseEvent, CronTick +from everos.infra.ome.events import BaseEvent, CronTick, ManualTick from everos.infra.ome.gates import Counter from everos.infra.ome.triggers import Cron, Immediate @@ -169,6 +169,37 @@ async def test_dispatch_strategy_filter_scopes_to_single_strategy( assert [m.name for m, _ in routes] == ["s_a"] +@pytest.mark.asyncio +async def test_dispatch_strategy_filter_refuses_undeclared_event_class( + dispatcher: EventDispatcher, +) -> None: + # A bare ManualTick aimed at an ``Immediate(on=[_E])`` strategy must not + # reach the handler: it reads ``_E`` fields the tick does not carry. + dispatcher._registry.register(_make_strategy("s_a")) + routes = await dispatcher.dispatch( + ManualTick(strategy_name="s_a"), strategy_filter="s_a" + ) + assert routes == [] + + +@pytest.mark.asyncio +async def test_dispatch_manual_tick_reaches_a_cron_strategy( + dispatcher: EventDispatcher, +) -> None: + # ``POST /ome/trigger {"name": "reflect_episodes", "force": true}`` is the + # documented way to run a scheduled job now; CronTick and ManualTick carry + # the same single field, so the handler is safe. + @offline_strategy(name="weekly", trigger=Cron(expr="0 2 * * 1"), emits=[]) + async def _weekly(event: Any, ctx: StrategyContext) -> None: + return None + + dispatcher._registry.register(_weekly) + routes = await dispatcher.dispatch( + ManualTick(strategy_name="weekly"), strategy_filter="weekly" + ) + assert [m.name for m, _ in routes] == ["weekly"] + + @pytest.mark.asyncio async def test_dispatch_strategy_filter_unknown_raises( dispatcher: EventDispatcher, diff --git a/tests/unit/test_infra/test_ome/test_engine.py b/tests/unit/test_infra/test_ome/test_engine.py index 682ce3f63..0d7f57710 100644 --- a/tests/unit/test_infra/test_ome/test_engine.py +++ b/tests/unit/test_infra/test_ome/test_engine.py @@ -292,6 +292,30 @@ async def s(event: ManualTick, ctx: StrategyContext) -> None: assert len(seen) == 1 +@pytest.mark.asyncio +async def test_trigger_manual_default_tick_skips_strategy_not_listening_to_it( + cfg: OMEConfig, +) -> None: + # ``POST /ome/trigger`` on a business-event strategy: no ManualTick in + # ``on`` → empty routes (``not_dispatched``), handler never sees the tick. + seen: list = [] + + @offline_strategy(name="on_e_only", trigger=Immediate(on=[_E]), emits=[]) + async def s(event: _E, ctx: StrategyContext) -> None: + seen.append(event) + + engine = OfflineEngine(config=cfg) + engine.register(s) + await engine.start() + try: + _, routes = await engine.trigger_manual("on_e_only") + await asyncio.sleep(0.2) + finally: + await engine.stop() + assert routes == [] + assert seen == [] + + @pytest.mark.asyncio async def test_trigger_manual_force_bypasses_enabled( cfg: OMEConfig, diff --git a/tests/unit/test_memory/test_cascade/test_orchestrator.py b/tests/unit/test_memory/test_cascade/test_orchestrator.py index d67bbf6a8..7a7601880 100644 --- a/tests/unit/test_memory/test_cascade/test_orchestrator.py +++ b/tests/unit/test_memory/test_cascade/test_orchestrator.py @@ -186,6 +186,7 @@ async def test_maintenance_cadences_reach_the_worker_from_settings( monkeypatch.setenv("EVEROS_CASCADE__OPTIMIZE_PRUNE_INTERVAL_SECONDS", "22") monkeypatch.setenv("EVEROS_CASCADE__OPTIMIZE_PRUNE_RETENTION_SECONDS", "33") monkeypatch.setenv("EVEROS_CASCADE__OPTIMIZE_REBUILD_INTERVAL_SECONDS", "44") + monkeypatch.setenv("EVEROS_CASCADE__SCAN_INTERVAL_SECONDS", "55") load_settings.cache_clear() # type: ignore[attr-defined] try: cfg = CascadeConfig.from_settings() @@ -194,7 +195,8 @@ async def test_maintenance_cadences_reach_the_worker_from_settings( cfg.optimize_prune_interval_seconds, cfg.optimize_prune_retention_seconds, cfg.optimize_rebuild_interval_seconds, - ) == (11.0, 22.0, 33.0, 44.0) + cfg.scan_interval_seconds, + ) == (11.0, 22.0, 33.0, 44.0, 55.0) orch = CascadeOrchestrator( memory_root=MemoryRoot.resolve(), tokenizer=build_tokenizer(), config=cfg @@ -204,6 +206,9 @@ async def test_maintenance_cadences_reach_the_worker_from_settings( assert worker._optimize_prune_interval == 22.0 assert worker._optimize_prune_retention == 33.0 assert worker._optimize_rebuild_interval == 44.0 + # The scanner is the other consumer; on an event-less mount it is the + # only one, so it must see the value too, not just CascadeConfig. + assert orch._scanner._interval == 55.0 finally: load_settings.cache_clear() # type: ignore[attr-defined] @@ -225,6 +230,7 @@ def test_deadlines_are_deliberately_not_configurable() -> None: "optimize_prune_interval_seconds", "optimize_prune_retention_seconds", "optimize_rebuild_interval_seconds", + "scan_interval_seconds", } assert not any("timeout" in f or "deadline" in f for f in exposed) diff --git a/tests/unit/test_memory/test_cascade/test_watcher_events.py b/tests/unit/test_memory/test_cascade/test_watcher_events.py new file mode 100644 index 000000000..e3bafeffe --- /dev/null +++ b/tests/unit/test_memory/test_cascade/test_watcher_events.py @@ -0,0 +1,404 @@ +"""Watcher behaviour on a real filesystem, asserted in the state table. + +The pure helpers are covered in ``test_watcher_helpers.py``. This module +covers everything else: the four ``_Handler`` callbacks, the two drop paths +in ``_enqueue``, ``start()`` creating the root, and -- the part only a real +observer can prove -- that this OS's filesystem events reach +``md_change_state`` at all. + +That last part is what makes the Windows CI job an oracle. inotify, FSEvents +and ReadDirectoryChangesW disagree about what an editor's save looks like. +Windows reports ``os.replace`` over an existing file as a REMOVED of the +target followed by a RENAMED pair, so ``on_deleted`` fires for a path that +still exists -- the shape that, unguarded, drives the worker to +``delete_by_md_path`` and wipes LanceDB while the md is fine. Every assertion +here is on the final row, never on event order, so one test holds on all +three backends. + +Assertions read ``md_change_state`` directly; that table owns the fact. +""" + +from __future__ import annotations + +import asyncio +import os +import time +from collections.abc import AsyncIterator, Callable +from pathlib import Path + +import pytest +from sqlmodel import SQLModel, select +from watchdog.events import ( + FileCreatedEvent, + FileDeletedEvent, + FileModifiedEvent, + FileMovedEvent, +) + +from everos.core.persistence import MemoryRoot +from everos.core.persistence.sqlite import session_scope +from everos.infra.persistence.sqlite import ( + MdChangeState, + dispose_engine, + get_engine, + get_session_factory, + md_change_state_repo, +) +from everos.memory.cascade import watcher as watcher_mod +from everos.memory.cascade.registry import match_kind +from everos.memory.cascade.scanner import CascadeScanner +from everos.memory.cascade.watcher import CascadeWatcher, _enqueue_async, _Handler + +_EPISODE_DIR = ("default_app", "default_project", "users", "u1", "episodes") +# windows-latest delivers in about a second; the rest is margin for a loaded +# runner. A passing test never waits this long. +_EVENT_DEADLINE_S = 15.0 +# Long enough for a trailing REMOVED / RENAMED leg to land after the row first +# appeared, so a "never deleted" assertion is not just "not deleted yet". +_TRAILING_EVENT_GRACE_S = 1.5 + + +@pytest.fixture +async def runtime( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[MemoryRoot]: + """Boot the system db against a tmp memory_root; no LanceDB needed here.""" + monkeypatch.setenv("EVEROS_ROOT", str(tmp_path)) + await dispose_engine() + engine = get_engine() + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + mr = MemoryRoot.resolve() + mr.ensure() + yield mr + await dispose_engine() + + +@pytest.fixture +async def watcher(runtime: MemoryRoot) -> AsyncIterator[CascadeWatcher]: + """A real observer on the memory root -- inotify / FSEvents / Win32.""" + w = CascadeWatcher(runtime, asyncio.get_running_loop()) + w.start() + # Let the native observer thread arm before the test writes, so the very + # first event is not lost to a race with startup. + await asyncio.sleep(0.5) + yield w + w.stop() + + +def _episode(root: Path, name: str = "episode-2026-01-01.md") -> Path: + d = root.joinpath(*_EPISODE_DIR) + d.mkdir(parents=True, exist_ok=True) + return d / name + + +def _rel(root: Path, p: Path) -> str: + return p.resolve().relative_to(root).as_posix() + + +async def _row(md_path: str) -> MdChangeState | None: + async with session_scope(get_session_factory()) as s: + stmt = select(MdChangeState).where(MdChangeState.md_path == md_path) + return (await s.execute(stmt)).scalars().first() + + +async def _all_paths() -> list[str]: + async with session_scope(get_session_factory()) as s: + rows = (await s.execute(select(MdChangeState))).scalars().all() + return sorted(r.md_path for r in rows) + + +async def _wait_row( + md_path: str, + where: Callable[[MdChangeState], bool] = lambda _r: True, + *, + deadline: float = _EVENT_DEADLINE_S, +) -> MdChangeState: + """Poll for a row matching ``where``; on timeout say what IS there.""" + end = time.monotonic() + deadline + last: MdChangeState | None = None + while time.monotonic() < end: + last = await _row(md_path) + if last is not None and where(last): + return last + await asyncio.sleep(0.1) + seen = f"row is {last.change_type!r}/{last.status!r}" if last else "no row" + pytest.fail( + f"{md_path!r}: {seen} after {deadline}s; rows present: {await _all_paths()}" + ) + + +async def _settle() -> None: + """Let a ``run_coroutine_threadsafe`` hop land on this loop.""" + for _ in range(5): + await asyncio.sleep(0.02) + + +# ── _Handler callbacks, called directly ───────────────────────────────────── + + +async def test_created_enqueues_added(runtime: MemoryRoot) -> None: + p = _episode(runtime.root) + p.write_text("x", encoding="utf-8") + _Handler(runtime, asyncio.get_running_loop()).on_created(FileCreatedEvent(str(p))) + await _settle() + row = await _wait_row(_rel(runtime.root, p), deadline=2) + assert (row.kind, row.change_type, row.status) == ("episode", "added", "pending") + assert row.mtime > 0 + + +async def test_modified_enqueues_modified(runtime: MemoryRoot) -> None: + p = _episode(runtime.root) + p.write_text("x", encoding="utf-8") + _Handler(runtime, asyncio.get_running_loop()).on_modified(FileModifiedEvent(str(p))) + await _settle() + row = await _wait_row(_rel(runtime.root, p), deadline=2) + assert row.change_type == "modified" + + +async def test_deleted_for_a_path_that_still_exists_is_ignored( + runtime: MemoryRoot, +) -> None: + """The LanceDB-wipe guard. + + FSEvents (``os.replace``) and ReadDirectoryChangesW (REMOVED of an + overwritten target) both hand the handler a deletion for a path that is + still there. Propagating it would enqueue ``deleted`` and the worker would + drop the rows for a file that is intact. + """ + p = _episode(runtime.root) + p.write_text("still here", encoding="utf-8") + _Handler(runtime, asyncio.get_running_loop()).on_deleted(FileDeletedEvent(str(p))) + await _settle() + assert await _row(_rel(runtime.root, p)) is None + + +async def test_deleted_for_a_gone_path_enqueues_deleted_with_zero_mtime( + runtime: MemoryRoot, +) -> None: + p = _episode(runtime.root) # directory exists, file never written + _Handler(runtime, asyncio.get_running_loop()).on_deleted(FileDeletedEvent(str(p))) + await _settle() + row = await _wait_row(_rel(runtime.root, p), deadline=2) + assert (row.change_type, row.mtime) == ("deleted", 0.0) + + +async def test_modified_for_a_gone_path_is_recorded_as_deleted( + runtime: MemoryRoot, +) -> None: + """A stale ``modified`` must not resurrect a deleted file. + + FSEvents can deliver the modified leg of a create after the unlink that + followed it (see ``test_unlink_enqueues_deleted``, which flaked 2 in 6 on + macOS before this guard). Disk is the truth: a modification reported for + a path that is no longer there is a deletion. + """ + p = _episode(runtime.root) # directory exists, file never written + _Handler(runtime, asyncio.get_running_loop()).on_modified(FileModifiedEvent(str(p))) + await _settle() + row = await _wait_row(_rel(runtime.root, p), deadline=2) + assert (row.change_type, row.mtime) == ("deleted", 0.0) + + +async def test_moved_enqueues_source_deleted_and_dest_added( + runtime: MemoryRoot, +) -> None: + src = _episode(runtime.root, "episode-2026-01-01.md") # not on disk + dest = _episode(runtime.root, "episode-2026-01-02.md") + dest.write_text("moved", encoding="utf-8") + _Handler(runtime, asyncio.get_running_loop()).on_moved( + FileMovedEvent(str(src), str(dest)) + ) + await _settle() + assert (await _wait_row(_rel(runtime.root, src), deadline=2)).change_type == ( + "deleted" + ) + assert (await _wait_row(_rel(runtime.root, dest), deadline=2)).change_type == ( + "added" + ) + + +async def test_moved_keeps_source_when_it_still_exists(runtime: MemoryRoot) -> None: + """A hardlink survives the rename, so the named path is still bound.""" + src = _episode(runtime.root, "episode-2026-01-01.md") + dest = _episode(runtime.root, "episode-2026-01-02.md") + src.write_text("linked", encoding="utf-8") + try: + os.link(src, dest) + except OSError as exc: # filesystem without hardlinks + pytest.skip(f"hardlinks unavailable here: {exc}") + _Handler(runtime, asyncio.get_running_loop()).on_moved( + FileMovedEvent(str(src), str(dest)) + ) + await _settle() + assert (await _wait_row(_rel(runtime.root, dest), deadline=2)).change_type == ( + "added" + ) + assert await _row(_rel(runtime.root, src)) is None + + +async def test_path_outside_root_is_dropped(runtime: MemoryRoot) -> None: + outside = runtime.root.parent.joinpath("elsewhere", *_EPISODE_DIR, "episode-x.md") + _Handler(runtime, asyncio.get_running_loop()).on_created( + FileCreatedEvent(str(outside)) + ) + await _settle() + assert await _all_paths() == [] + + +async def test_path_not_matching_a_kind_is_dropped( + runtime: MemoryRoot, monkeypatch: pytest.MonkeyPatch +) -> None: + """Dropped *before* the async hop, not lost inside it. + + "No row" alone cannot tell a clean drop from a failure downstream: an + exception inside ``_enqueue_async`` -- logged, or left unretrieved in the + ``run_coroutine_threadsafe`` future -- also leaves no row. Recording what + the guard hands downstream is what separates the two. + """ + handed_down: list[tuple[object, ...]] = [] + + async def record(*a: object, **k: object) -> None: + handed_down.append(a) + + monkeypatch.setattr(watcher_mod, "_enqueue_async", record) + p = runtime.root / "notes" / "random.md" + p.parent.mkdir(parents=True) + p.write_text("x", encoding="utf-8") + _Handler(runtime, asyncio.get_running_loop()).on_created(FileCreatedEvent(str(p))) + await _settle() + assert handed_down == [], "a path with no kind reached the enqueue coroutine" + assert await _all_paths() == [] + + +async def test_upsert_failure_is_logged_not_raised( + runtime: MemoryRoot, monkeypatch: pytest.MonkeyPatch +) -> None: + """The callback runs on the watchdog thread; an escape would kill it.""" + + async def boom(*_a: object, **_k: object) -> int: + raise RuntimeError("sqlite is having a day") + + monkeypatch.setattr(md_change_state_repo, "upsert", boom) + spec = match_kind("/".join((*_EPISODE_DIR, "episode-x.md"))) + assert spec is not None + await _enqueue_async(spec, "whatever.md", "added", 1.0) # must not raise + + +async def test_start_creates_a_missing_root_and_stop_is_clean(tmp_path: Path) -> None: + """watchdog refuses a non-existent path; ``start()`` has to make it first.""" + mr = MemoryRoot(tmp_path / "not-yet") + assert not mr.root.exists() + w = CascadeWatcher(mr, asyncio.get_running_loop()) + w.start() + try: + assert mr.root.is_dir() + finally: + w.stop() + + +# ── a real observer: does this OS deliver at all? ─────────────────────────── + + +async def test_observer_delivers_a_new_file( + runtime: MemoryRoot, watcher: CascadeWatcher +) -> None: + p = _episode(runtime.root) + p.write_text("hello", encoding="utf-8") + row = await _wait_row(_rel(runtime.root, p)) + assert row.kind == "episode" + assert row.change_type in {"added", "modified"} + assert row.status == "pending" + assert row.mtime > 0 + + +async def test_in_place_save_never_registers_as_deleted( + runtime: MemoryRoot, watcher: CascadeWatcher +) -> None: + """Notepad / VS Code / Obsidian truncate and rewrite the same inode.""" + p = _episode(runtime.root) + p.write_text("v1", encoding="utf-8") + rel = _rel(runtime.root, p) + await _wait_row(rel) + first = await _row(rel) + assert first is not None + p.write_text("v2 -- same file, rewritten in place", encoding="utf-8") + await asyncio.sleep(_TRAILING_EVENT_GRACE_S) + row = await _row(rel) + assert row is not None + assert row.change_type != "deleted" + assert row.status == "pending" + assert row.mtime > first.mtime, "the in-place save was never delivered" + + +async def test_atomic_replace_over_existing_target_keeps_the_row_alive( + runtime: MemoryRoot, watcher: CascadeWatcher +) -> None: + """Write-temp-then-``os.replace`` is how many editors save. + + Windows reports it as REMOVED(target) + RENAMED(tmp -> target); FSEvents + as a synthetic deletion of the old inode plus a move. In both, a deletion + arrives for a path that still exists. Whatever the order, the target's row + must end up live and the temp file must never have been enqueued. + """ + target = _episode(runtime.root) + target.write_text("v1", encoding="utf-8") + rel = _rel(runtime.root, target) + await _wait_row(rel) + + first = await _row(rel) + assert first is not None + tmp = target.with_name(target.name + ".tmp") # not kind-matched + tmp.write_text("v2 via atomic save", encoding="utf-8") + os.replace(tmp, target) + + await asyncio.sleep(_TRAILING_EVENT_GRACE_S) + row = await _row(rel) + assert row is not None, f"target row vanished; rows: {await _all_paths()}" + assert row.change_type != "deleted", ( + "an atomic save was recorded as a deletion -- the worker would wipe " + "this file's LanceDB rows while the md is intact" + ) + assert row.status == "pending" + assert row.mtime > first.mtime, "the atomic save was never delivered" + assert await _row(_rel(runtime.root, tmp)) is None + assert target.read_text(encoding="utf-8") == "v2 via atomic save" + + +async def test_unlink_enqueues_deleted( + runtime: MemoryRoot, watcher: CascadeWatcher +) -> None: + p = _episode(runtime.root) + p.write_text("doomed", encoding="utf-8") + rel = _rel(runtime.root, p) + await _wait_row(rel) + p.unlink() + row = await _wait_row(rel, lambda r: r.change_type == "deleted") + assert row.mtime == 0.0 + + +async def test_rename_within_root_moves_the_row( + runtime: MemoryRoot, watcher: CascadeWatcher +) -> None: + """The destination is the watcher's job; the source is the sweep's. + + ReadDirectoryChangesW reports a rename as RENAMED_OLD then RENAMED_NEW, + and watchdog pairs them only when both land in the same read -- the + pairing variable is local to one ``queue_events`` call. Split across two + reads, the OLD leg is dropped and the watcher never learns the source + path (CI saw the source row sit at ``added`` for 15 s). That is not a + defect the watcher can fix; it is why the scanner exists: a state row + whose path is gone from disk is re-emitted as ``deleted`` on the next + sweep. So assert the immediate leg on the watcher and the source leg + after one sweep, which is the contract the system actually offers on + every backend. + """ + a = _episode(runtime.root, "episode-2026-01-01.md") + b = _episode(runtime.root, "episode-2026-01-02.md") + a.write_text("renamed later", encoding="utf-8") + rel_a, rel_b = _rel(runtime.root, a), _rel(runtime.root, b) + await _wait_row(rel_a) + os.rename(a, b) + assert (await _wait_row(rel_b)).change_type in {"added", "modified"} + await CascadeScanner(runtime).scan_once(kinds={"episode"}) + await _wait_row(rel_a, lambda r: r.change_type == "deleted", deadline=5) diff --git a/tests/unit/test_memory/test_cascade/test_worker.py b/tests/unit/test_memory/test_cascade/test_worker.py index e7953c34a..e2f71620e 100644 --- a/tests/unit/test_memory/test_cascade/test_worker.py +++ b/tests/unit/test_memory/test_cascade/test_worker.py @@ -724,7 +724,9 @@ async def test_rebuild_runs_periodically( optimize_rebuild_interval_seconds=0.05, # ~tick every 50ms in this test ) await w.start() - await asyncio.sleep(0.2) # ~4 ticks plus startup sweep + # Windows' ~15ms timer granularity stretches a 50ms interval to ~110ms, + # so size the window off the observed tick, not the requested one. + await asyncio.sleep(0.6) await w.stop() # Startup sweep + at least 2 interval-driven sweeps. assert len(fake.rebuild_calls) >= 3, ( diff --git a/tests/unit/test_memory/test_extract/test_parser/test_enrich.py b/tests/unit/test_memory/test_extract/test_parser/test_enrich.py index acd9793b4..1badd5ba2 100644 --- a/tests/unit/test_memory/test_extract/test_parser/test_enrich.py +++ b/tests/unit/test_memory/test_extract/test_parser/test_enrich.py @@ -156,7 +156,7 @@ async def fake_aparse(raw_file: Any) -> ParsedContent: monkeypatch.setattr(_APARSE_FILE_TARGET, fake_aparse) f = tmp_path / "doc.html" f.write_bytes(b"hello") - items = [{"type": "html", "uri": f"file://{f}"}] + items = [{"type": "html", "uri": f.as_uri()}] await enrich_content_items(items) assert items[0]["parsed_content"] == "FILE PARSED" diff --git a/tests/unit/test_memory/test_extract/test_parser/test_mapping.py b/tests/unit/test_memory/test_extract/test_parser/test_mapping.py index f6746aeca..41296ea2a 100644 --- a/tests/unit/test_memory/test_extract/test_parser/test_mapping.py +++ b/tests/unit/test_memory/test_extract/test_parser/test_mapping.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import json from pathlib import Path import pytest @@ -54,7 +55,7 @@ async def test_build_raw_file_hydrates_file_uri(tmp_path: Path) -> None: """file:// is read locally into a hydrated RawFile (content + ext).""" f = tmp_path / "notes.html" f.write_bytes(b"v9.9.9") - rf = await build_raw_file({"type": "html", "uri": f"file://{f}"}) + rf = await build_raw_file({"type": "html", "uri": f.as_uri()}) assert rf.content == b"v9.9.9" assert rf.extension == "html" assert rf.uri == "" # hydrated, not a pointer @@ -63,13 +64,13 @@ async def test_build_raw_file_hydrates_file_uri(tmp_path: Path) -> None: async def test_build_raw_file_file_uri_ext_hint_wins(tmp_path: Path) -> None: f = tmp_path / "blob" # no suffix f.write_bytes(b"%PDF-1.4 ...") - rf = await build_raw_file({"type": "pdf", "uri": f"file://{f}", "ext": "pdf"}) + rf = await build_raw_file({"type": "pdf", "uri": f.as_uri(), "ext": "pdf"}) assert rf.extension == "pdf" async def test_build_raw_file_missing_file_raises(tmp_path: Path) -> None: with pytest.raises(ValueError): - await build_raw_file({"type": "pdf", "uri": f"file://{tmp_path}/nope.pdf"}) + await build_raw_file({"type": "pdf", "uri": (tmp_path / "nope.pdf").as_uri()}) async def test_build_raw_file_oversize_raises( @@ -80,7 +81,7 @@ async def test_build_raw_file_oversize_raises( monkeypatch.setenv("EVEROS_MULTIMODAL__FILE_URI_MAX_BYTES", "10") load_settings.cache_clear() with pytest.raises(ValueError, match="too large"): - await build_raw_file({"type": "html", "uri": f"file://{f}"}) + await build_raw_file({"type": "html", "uri": f.as_uri()}) async def test_build_raw_file_outside_allowlist_raises( @@ -91,7 +92,7 @@ async def test_build_raw_file_outside_allowlist_raises( monkeypatch.setenv("EVEROS_MULTIMODAL__FILE_URI_ALLOW_DIRS", '["/some/other/root"]') load_settings.cache_clear() with pytest.raises(ValueError, match="outside the allowed roots"): - await build_raw_file({"type": "html", "uri": f"file://{f}"}) + await build_raw_file({"type": "html", "uri": f.as_uri()}) async def test_build_raw_file_inside_allowlist_ok( @@ -99,7 +100,9 @@ async def test_build_raw_file_inside_allowlist_ok( ) -> None: f = tmp_path / "ok.html" f.write_bytes(b"ok") - monkeypatch.setenv("EVEROS_MULTIMODAL__FILE_URI_ALLOW_DIRS", f'["{tmp_path}"]') + monkeypatch.setenv( + "EVEROS_MULTIMODAL__FILE_URI_ALLOW_DIRS", json.dumps([str(tmp_path)]) + ) load_settings.cache_clear() - rf = await build_raw_file({"type": "html", "uri": f"file://{f}"}) + rf = await build_raw_file({"type": "html", "uri": f.as_uri()}) assert rf.content == b"ok" diff --git a/tests/unit/test_package_init_windows_dlls.py b/tests/unit/test_package_init_windows_dlls.py new file mode 100644 index 000000000..c3bf9b837 --- /dev/null +++ b/tests/unit/test_package_init_windows_dlls.py @@ -0,0 +1,87 @@ +"""The package-init hook that lets compiled extensions find the MSVC runtime. + +The hook runs at ``import everos``; these tests call the factored function +with a fake prefix and a recorded ``os.add_dll_directory`` so they hold on +every platform. What only Windows can prove -- that greenlet then actually +loads on a machine without the redistributable -- was proven by hand on a +stock Windows 11 Enterprise box; see the commit that introduced this. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +import everos + + +def _recording_add(seen: list[str]): # type: ignore[no-untyped-def] + def add(d: str) -> object: + seen.append(d) + return object() + + return add + + +def test_registers_prefix_and_scripts_when_the_runtime_dll_is_there( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "msvcp140.dll").write_bytes(b"") + (tmp_path / "Scripts").mkdir() + (tmp_path / "Scripts" / "msvcp140.dll").write_bytes(b"") + seen: list[str] = [] + monkeypatch.setattr(os, "add_dll_directory", _recording_add(seen), raising=False) + + got = everos._register_runtime_dll_dirs( + str(tmp_path), user_base=str(tmp_path / "ub") + ) + + assert got == seen == [str(tmp_path), str(tmp_path / "Scripts")] + + +def test_registers_the_per_user_install_dir_too( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``pip install`` without write access to site-packages lands the wheel's + data files under ``site.getuserbase()``, not ``sys.prefix``.""" + prefix = tmp_path / "prefix" + prefix.mkdir() + user_base = tmp_path / "AppData" / "Python" + (user_base / "Scripts").mkdir(parents=True) + (user_base / "msvcp140.dll").write_bytes(b"") + (user_base / "Scripts" / "msvcp140.dll").write_bytes(b"") + seen: list[str] = [] + monkeypatch.setattr(os, "add_dll_directory", _recording_add(seen), raising=False) + + got = everos._register_runtime_dll_dirs(str(prefix), user_base=str(user_base)) + + assert got == seen == [str(user_base), str(user_base / "Scripts")] + + +def test_skips_directories_without_the_dll( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Registering a dir with no runtime in it would only widen the search + path for nothing; the dll is the signal that msvc-runtime is present.""" + (tmp_path / "Scripts").mkdir() + seen: list[str] = [] + monkeypatch.setattr(os, "add_dll_directory", _recording_add(seen), raising=False) + + assert ( + everos._register_runtime_dll_dirs(str(tmp_path), user_base=str(tmp_path)) == [] + ) + assert seen == [] + + +def test_is_a_no_op_where_the_os_has_no_add_dll_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """POSIX: the dll may even be there (a shared checkout), still nothing.""" + (tmp_path / "msvcp140.dll").write_bytes(b"") + monkeypatch.delattr(os, "add_dll_directory", raising=False) + + assert ( + everos._register_runtime_dll_dirs(str(tmp_path), user_base=str(tmp_path)) == [] + ) diff --git a/uv.lock b/uv.lock index 678b86ace..ba4f78ecb 100644 --- a/uv.lock +++ b/uv.lock @@ -598,6 +598,7 @@ dependencies = [ { name = "greenlet" }, { name = "jieba" }, { name = "lancedb" }, + { name = "msvc-runtime", marker = "sys_platform == 'win32'" }, { name = "openai" }, { name = "portalocker" }, { name = "prometheus-client" }, @@ -662,6 +663,7 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.0" }, { name = "jieba", specifier = ">=0.42.1,<1.0" }, { name = "lancedb", specifier = ">=0.34.0,<0.35.0" }, + { name = "msvc-runtime", marker = "sys_platform == 'win32'", specifier = ">=14.44" }, { name = "openai", specifier = ">=1.0.0" }, { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1.27.0" }, { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.27.0" }, @@ -1358,6 +1360,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "msvc-runtime" +version = "14.44.35112" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/7d/f86050e24fd78e76e56145b326bfd3e7ae2c028e87e0732802a0c5144223/msvc_runtime-14.44.35112-cp312-cp312-win32.whl", hash = "sha256:7c5af6756056dbf6f3593f69b0f4575dbbd9e8ec10d3108ff00437da811a7b67", size = 1921505, upload-time = "2025-08-08T03:57:54.509Z" }, + { url = "https://files.pythonhosted.org/packages/21/3b/134d04268ab8e35853cd007582076429b45d60d6abb1036d159be9c50342/msvc_runtime-14.44.35112-cp312-cp312-win_amd64.whl", hash = "sha256:32f9c706009e16ccc319d6947ce3bffe20e5192bee52b18cf48313f9e7bedfbe", size = 1921899, upload-time = "2025-08-08T03:57:55.648Z" }, + { url = "https://files.pythonhosted.org/packages/a0/38/f245d6d30a76655293575683aaa5f72a7a70fc8efa1637bf6fd9e5e7e665/msvc_runtime-14.44.35112-cp312-cp312-win_arm64.whl", hash = "sha256:62f272c51f30ce0de1b7a796e50af4d3911b76cbc9bc909917435d094f68d5fe", size = 3208186, upload-time = "2025-08-08T03:57:57.401Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/590fcbf92fee8f0d3ac46cd8b32069b058d6ce7bfb8be6c07a35d9c9188b/msvc_runtime-14.44.35112-cp313-cp313-win32.whl", hash = "sha256:e60781114472891c34fe14cc45a19ac28d80e0084dfbce882b7475c40c743152", size = 1921506, upload-time = "2025-08-08T03:57:58.888Z" }, + { url = "https://files.pythonhosted.org/packages/fe/17/7a1eace5a7c6083bd99db861625c08bb37010dfbe90ed7a0194ae0281689/msvc_runtime-14.44.35112-cp313-cp313-win_amd64.whl", hash = "sha256:d4f6cf106aaf235f2a90952f9ec7de49e9946323880d051abf9745a6d4bf60bf", size = 1921893, upload-time = "2025-08-08T03:58:00.234Z" }, + { url = "https://files.pythonhosted.org/packages/33/0d/88d6183846ad33b4a8fcb82fc86f26f9b7e21d776a925b6c8ab89618234a/msvc_runtime-14.44.35112-cp313-cp313-win_arm64.whl", hash = "sha256:359152dc9769559fee4ffdaa19f1fc2b72ab1535631a6105116776b263a235ac", size = 3208191, upload-time = "2025-08-08T03:58:02.35Z" }, + { url = "https://files.pythonhosted.org/packages/d7/9c/b9ee5402fe9daf6f4dba458f11238289c606df05e0cc9f0750d5ada4ff3b/msvc_runtime-14.44.35112-cp314-cp314-win32.whl", hash = "sha256:99c22caa2755c76cfa34b741e96bee99c461480d709fb9c582492ccc0f03c7ae", size = 1976509, upload-time = "2025-08-08T03:58:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/d2/48/7fb574a4bfa697574a7dc7105bc15ef6882125e7c77c794c5e73429de832/msvc_runtime-14.44.35112-cp314-cp314-win_amd64.whl", hash = "sha256:af179a6c552070e660493765efd5856283af3fed971ddc3577203a7d3f1ee8e7", size = 1976906, upload-time = "2025-08-08T03:58:05.079Z" }, + { url = "https://files.pythonhosted.org/packages/93/b3/bdb7c215d1080ea08deb5e151dc261f74f5b1d25544947e31e902eb1253f/msvc_runtime-14.44.35112-cp314-cp314-win_arm64.whl", hash = "sha256:81eb9346ab2a269ea934bf13492fe5c1cd50af871a2fbb973f0a514031e81b6a", size = 3300540, upload-time = "2025-08-08T03:58:06.52Z" }, + { url = "https://files.pythonhosted.org/packages/95/99/271ff26cf07d875766efa4a376fe9415c033f07fe85a1371a071aaa37aca/msvc_runtime-14.44.35112-cp314-cp314t-win32.whl", hash = "sha256:38d64f2db707be465a79c87f0bec347249af89614550d10ed0345f6a7f80cf73", size = 1976674, upload-time = "2025-08-08T03:58:07.902Z" }, + { url = "https://files.pythonhosted.org/packages/47/eb/76fd58fd64209e7f3dbfe59237e2c49ee6bdd4c21eba9efe5a2354dacd3e/msvc_runtime-14.44.35112-cp314-cp314t-win_amd64.whl", hash = "sha256:1105f8f8117ee210b85bd8c771dbefff8f8d35771ee7ce3291ddd91d2a03eff1", size = 1977068, upload-time = "2025-08-08T03:58:09.376Z" }, + { url = "https://files.pythonhosted.org/packages/e2/38/b1ff1734e257c99a6450c60a1632d0c9ddbdb049ee4095439a591a0949a5/msvc_runtime-14.44.35112-cp314-cp314t-win_arm64.whl", hash = "sha256:a2ab094e35fa04172f6fd5cfc7d2a2017755ea9c3ff39cad5c2a4456e0bff689", size = 3300780, upload-time = "2025-08-08T03:58:11.061Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0"