**Phase 194 — final verification pass: all green.**
**Verified (all 5 tasks already complete; nothing re-implemented needed)**
- `app/rag/importer.py`: `_prescan_pending` + `_pending_hash_stmt` (scalar `documents.file_hash`, own session, ONE `asyncio.to_thread` span, REPLACES the stats pre-walk), `on_scan_progress`, `progress` pair = pending position, `0` branch = byte-identical phase-193 walk.
- `app/config.py`/`settings_catalog.py`/`.env.example`: `sync_pending_count=1` + validator; `app/api/sync.py`: `"scan"` phase, `scan_done`/`scan_total` (additive after `files_total`, kept at every terminal), forced/throttled RSS split; `frontend/assets/sources.js`: `fmtSyncPhaseLabel` scan branch first-arm, `fmtSyncLabel` untouched.
**Tests / lint / coverage (exact commands)**
- `uv run pytest --cov=app --cov-report=term-missing` → **4835 passed**, `app/` 99% (7240 stmts / 16 miss), exit 0
- `uv run pytest tests/e2e/test_sync_pending_count.py -v --no-cov` → **8 passed in 150.81s** (phase suite, isolated)
- 14 sync-lineage E2E suites, one pytest process each (`test_sync_remaining_count`, `…_chunk_progress`, `…_progress_box`, `…_peak_memory_visible`, `…_label_mobile`, `…_upload_progress`, `test_archive_upload_sources`, `…_image_progress`, `…_summary_visibility`, `…_status_freshness`, `test_smoke`, `test_fast_path_deferred_content`, `test_sync_session_reset`, `test_sync_git_freeze`) → **all exit 0**
- `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**
**Completion criteria** — all met: `N remaining` = predicted change set (E2E arms 1/2/5, first import counts the walk, no-change → no count part); `scan` phase visible + flips to `import`, scan pair reset/kept at terminals (`test_sync_scan_phase.py`); `BOR_SYNC_PENDING_COUNT=0` walk semantics ("7 remaining" arm) + **negative refuses startup naming the field (verified live by importing `app.main`)**; pre-scan off-loop + cancel lands `cancelled` (integration pins); CLI/upload byte-identical (unedited pins green); payload additive (`_PRE_187_STATUS_KEYS | _MEMORY_KEYS | _SCAN_KEYS`); suite green, coverage >90%, lint/types clean.
**Documentation** — 3 fixes: README sync "States" bullet had an **unclosed parenthesis** (pre-existing, 193/194) and the scan-label "total is 0" clause was ambiguous about a `=0` run (it still counts the walk once the import starts); **AGENTS.md rule 17 had a stray `)`** (pre-existing from phase 191). Also verified consistency both directions for all 87 settings ↔ README ↔ `.env.example` ↔ settings catalog, the pages table ↔ `app/main.py` routes, no stale `BOR_` names, and README/AGENTS claims through phase 194.
**Notes/deviations** — no live-infra changes (prod sync knob ships default-on, no deployment touched); no git index/history commands run (all changes left in the working tree for the harness); commit criterion left to the harness as instructed.
**Next pending phase:** none — `.agents/phases/todo/` holds only `194_sync_pending_count`, which is now ready to move to `complete/`.
701 lines
26 KiB
Python
701 lines
26 KiB
Python
"""Phase 194, task 01 — the importer's pre-scan + the pending-count switch.
|
|
|
|
The owner report that opened the phase (2026-10-19): *"I just synced the prod
|
|
brain of reese and it said '1216 remaining' when it only had like 10 files to
|
|
sync."* The arithmetic of the phase-193 label was fine; its INPUTS were not —
|
|
``total`` was every importable file (prod: ~10,000) and ``done`` was the
|
|
position in that walk, so the label counted the WALK instead of the WORK.
|
|
Task 01 moves the pair to the PREDICTED change set: ``import_sources``
|
|
pre-scans (one streamed sha256 + one scalar ``documents.file_hash`` read per
|
|
importable file, off the event loop) and reports ``done``/``total`` as the
|
|
position in the pending set, with ``BOR_SYNC_PENDING_COUNT`` as the kill
|
|
switch (``0`` = the exact phase-193 walk position, negative = refuses
|
|
startup).
|
|
|
|
Pinned here (the behaviour under a real run — the pending-set parity, the
|
|
zero-hydration pin, the off-loop span and the cancel — lives in
|
|
``tests/integration/test_sync_pending_count.py``):
|
|
|
|
* the GATE: no ``progress`` caller means no scan at all (the callback never
|
|
fires and the scalar statement is never built — the phase-64 skip rule,
|
|
extended), and the switch at ``0`` on a progress caller runs the phase-64
|
|
stats pre-walk instead (a source marker pin: that old code IS the ``0``
|
|
branch, and a behaviour pin on the pair it reports);
|
|
* the switch is read ONCE per run (the phase-184 read-once discipline), on
|
|
the worker-thread side of ``asyncio.to_thread`` (the phase-179 rule);
|
|
* the scalar statement's projection is EXACTLY ``documents.file_hash`` — one
|
|
scalar column, no entity, no ``defer``/``load_only`` row (the phase-185
|
|
column-list style); :func:`_doc_row_stmt` stays the importer's only ROW
|
|
load;
|
|
* the pending PREDICTATE: fresh (no row), changed (digest mismatch) and a
|
|
pre-phase ``NULL`` row are pending, a hash match is not, a file the ignore
|
|
rule hides is never even walked, and a file that cannot be read is
|
|
predicted changed rather than fatal;
|
|
* ``on_scan_progress``: once per file, the 1-based position against the
|
|
combined importable total, and NOTHING for an empty KB (the phase-64
|
|
``total === 0`` contract);
|
|
* ``done`` as the pending position: a 3-file KB with 2 predicted reports
|
|
``1``, ``1``, ``2`` against ``total = 2`` — the file that is not predicted
|
|
holds the position while the walk moves past it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import asyncio
|
|
import inspect
|
|
import re
|
|
import threading
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from sqlalchemy import text
|
|
from sqlalchemy.dialects import postgresql
|
|
from sqlalchemy.orm import Session
|
|
|
|
import app.rag.importer as importer
|
|
from app.config import Settings
|
|
from app.models import Document
|
|
from app.rag.importer import (
|
|
_pending_hash_stmt,
|
|
_prescan_pending,
|
|
import_sources,
|
|
)
|
|
from tests.fakes import FakeEmbedder
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Helpers
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def _code_only(fn: Any) -> str:
|
|
"""A function's source WITHOUT its docstring.
|
|
|
|
The marker pins below count occurrences of names in the code; the
|
|
docstrings argue about those same names by name (the house convention), so
|
|
counting the raw source would pin prose. The docstring is located through
|
|
the AST and sliced out by its own end line.
|
|
"""
|
|
src = inspect.getsource(fn)
|
|
lines = src.splitlines()
|
|
node = ast.parse(src).body[0]
|
|
# `import_sources` is an ASYNC def, so it parses to AsyncFunctionDef —
|
|
# narrowing to FunctionDef alone would silently skip the docstring strip
|
|
# and pin prose.
|
|
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
return src
|
|
first = node.body[0]
|
|
if (
|
|
isinstance(first, ast.Expr)
|
|
and isinstance(first.value, ast.Constant)
|
|
and isinstance(first.value.value, str)
|
|
):
|
|
return "\n".join(lines[first.end_lineno or 1 :])
|
|
return src
|
|
|
|
|
|
def _settings(**overrides: Any) -> Settings:
|
|
"""Fresh settings, never the developer's ``.env`` (the house pattern)."""
|
|
return Settings(_env_file=None, **overrides) # pyright: ignore[reportCallIssue]
|
|
|
|
|
|
def _root(tmp_path: Path, name: str, files: dict[str, str]) -> Path:
|
|
"""One source root with *files* (relative path → content) under it."""
|
|
root = tmp_path / name
|
|
for rel, body in files.items():
|
|
target = root / rel
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(body, encoding="utf-8")
|
|
return root
|
|
|
|
|
|
def _prescan(
|
|
*sources: Path,
|
|
session: Session,
|
|
ignore_by_root: dict[str, list[str]] | None = None,
|
|
include_hidden_by_root: dict[str, bool] | None = None,
|
|
on_scan_progress: Any = None,
|
|
image_extensions: frozenset[str] = frozenset(),
|
|
) -> tuple[int, dict[tuple[str, str], int]]:
|
|
""":func:`_prescan_pending` with the shipped import extensions and an
|
|
explicit session, so the predicate is testable without a full run."""
|
|
return _prescan_pending(
|
|
list(sources),
|
|
_settings().import_extension_set,
|
|
image_extensions,
|
|
ignore_by_root,
|
|
include_hidden_by_root,
|
|
on_scan_progress,
|
|
session,
|
|
)
|
|
|
|
|
|
def _seed_row(
|
|
db: Session, source: str, rel: str, *, file_hash: str | None
|
|
) -> None:
|
|
"""One ``documents`` row with a KNOWN ``file_hash`` (the pre-scan reads
|
|
that one column, so this is the whole fixture: raw SQL, no importer, no
|
|
chunks)."""
|
|
db.execute(
|
|
text(
|
|
"INSERT INTO documents (id, source, path, full_path, title, content, "
|
|
"content_hash, file_hash, indexed_at, created_at, created_at_manual, "
|
|
"summary, is_image) VALUES (:id, :source, :path, :full_path, :title, "
|
|
":content, :content_hash, :file_hash, now(), now(), false, :summary, "
|
|
"false)"
|
|
),
|
|
{
|
|
"id": uuid.uuid4(),
|
|
"source": source,
|
|
"path": rel,
|
|
"full_path": f"/tmp/{source}/{rel}",
|
|
"title": rel,
|
|
"content": "stored markdown",
|
|
"content_hash": "content-digest",
|
|
"file_hash": file_hash,
|
|
"summary": "stored summary",
|
|
},
|
|
)
|
|
db.commit()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_probe_rows(db: Session) -> Any:
|
|
"""The probe's own source-name prefix, removed around every test (the
|
|
tables are global state — the ``test_importer.py`` hygiene pattern)."""
|
|
_drop(db)
|
|
yield
|
|
_drop(db)
|
|
|
|
|
|
def _drop(db: Session) -> None:
|
|
db.execute(
|
|
text("DELETE FROM documents WHERE source LIKE 'phase194-%'"),
|
|
)
|
|
db.commit()
|
|
db.expunge_all()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The gate: who pays for the scan
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_no_progress_caller_runs_no_scan_at_all(
|
|
db: Session, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""THE skip rule (phase 64), extended: ``progress=None`` → no pre-scan.
|
|
|
|
The CLI (``scripts/import_docs.py``) and the upload job pass no
|
|
``progress``, so they must not pay one extra read + hash + SELECT per
|
|
importable file. A recording stand-in for the span proves the gate
|
|
itself: the CLI-shaped call imports the file and never reaches the
|
|
scan, and an ``on_scan_progress`` hook handed to a ``progress=None``
|
|
caller fires never (the scan exists only to serve the running counts).
|
|
"""
|
|
calls: list[tuple[Any, ...]] = []
|
|
scan_events: list[tuple[int, int]] = []
|
|
|
|
def _spy(*args: Any, **kwargs: Any) -> tuple[int, dict[tuple[str, str], int]]:
|
|
calls.append(args)
|
|
return 0, {}
|
|
|
|
monkeypatch.setattr(importer, "_prescan_pending", _spy)
|
|
root = _root(tmp_path, "phase194-gate-cli", {"a.md": "# A\n\na\n"})
|
|
try:
|
|
summary = asyncio.run(import_sources([root], FakeEmbedder(), session=db))
|
|
assert summary.files == 1 and summary.added == 1
|
|
assert calls == [], "a progress=None caller ran the pre-scan"
|
|
|
|
# The scan hook alone does not open the gate either.
|
|
summary2 = asyncio.run(
|
|
import_sources(
|
|
[root],
|
|
FakeEmbedder(),
|
|
session=db,
|
|
on_scan_progress=lambda done, total: scan_events.append((done, total)),
|
|
)
|
|
)
|
|
assert summary2.files == 1
|
|
assert calls == []
|
|
assert scan_events == []
|
|
finally:
|
|
db.execute(
|
|
text("DELETE FROM documents WHERE source = :s"), {"s": root.name}
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def test_switch_zero_runs_the_phase_193_stats_prewalk(
|
|
db: Session, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The rollback branch, from both sides: the marker (the old stats
|
|
pre-walk code IS the ``0`` branch) and the reported pair.
|
|
|
|
With the switch at ``0`` a progress caller never reaches the scan span,
|
|
and the hook reports the phase-193 walk position byte-for-byte — every
|
|
importable file in ``total``, the 1-based index across all sources in
|
|
``done`` — on a run where nothing is pending. That last clause is the
|
|
point of the arm: the SAME unchanged fixture reports ``(0, 0)`` with the
|
|
switch on and ``(1, 1)``, ``(2, 2)`` with it off.
|
|
"""
|
|
calls: list[tuple[Any, ...]] = []
|
|
|
|
def _spy(*args: Any, **kwargs: Any) -> tuple[int, dict[tuple[str, str], int]]:
|
|
calls.append(args)
|
|
return 0, {}
|
|
|
|
monkeypatch.setattr(importer, "_prescan_pending", _spy)
|
|
root = _root(
|
|
tmp_path,
|
|
"phase194-gate-zero",
|
|
{"a.md": "# A\n\nalpha\n", "b.md": "# B\n\nbravo\n"},
|
|
)
|
|
llm = FakeEmbedder()
|
|
llm.settings = _settings(sync_pending_count=0)
|
|
events: list[tuple[str, str, int, int]] = []
|
|
try:
|
|
# Seed the KB (a walk-position run of its own), so nothing pending.
|
|
asyncio.run(import_sources([root], llm, session=db))
|
|
assert calls == []
|
|
|
|
events.clear()
|
|
summary = asyncio.run(
|
|
import_sources(
|
|
[root],
|
|
llm,
|
|
session=db,
|
|
progress=lambda s, r, d, t: events.append((s, r, d, t)),
|
|
)
|
|
)
|
|
assert calls == [], "the switch was 0 but the pre-scan ran"
|
|
assert (summary.files, summary.unchanged) == (2, 2)
|
|
assert events == [
|
|
(root.name, "a.md", 1, 2),
|
|
(root.name, "b.md", 2, 2),
|
|
], "the 0 branch must report the phase-193 walk position exactly"
|
|
finally:
|
|
db.execute(text("DELETE FROM documents WHERE source = :s"), {"s": root.name})
|
|
db.commit()
|
|
|
|
# The marker: the stats-only pre-walk still EXISTS in the importer, and
|
|
# it lives in the ``0`` branch (a third walk would mean the replacement
|
|
# was not a replacement).
|
|
src = _code_only(importer.import_sources)
|
|
assert "BOR_SYNC_PENDING_COUNT=0" in src
|
|
assert src.count("iter_importable_files(") == 2, (
|
|
"the importer walks the sources for the count more than once besides "
|
|
"the pre-scan — the pre-scan REPLACES the stats pre-walk (phase 194 "
|
|
"D1: no third walk)"
|
|
)
|
|
|
|
|
|
def test_switch_is_read_once_before_the_loop_and_off_the_loop() -> None:
|
|
"""The phase-184 read-once discipline + the phase-179 off-loop rule.
|
|
|
|
``llm.settings.sync_pending_count`` is read exactly ONCE, before the
|
|
source loop, so a settings overlay saved mid-run cannot flip the meaning
|
|
of ``done``/``total`` between two files of the same walk; and the span
|
|
is entered through ``asyncio.to_thread``, so a 10,000-file hash+SELECT
|
|
pass never parks the single uvicorn loop.
|
|
"""
|
|
src = _code_only(importer.import_sources)
|
|
|
|
assert src.count("llm.settings.sync_pending_count") == 1, (
|
|
"sync_pending_count is read more than once per run — a mid-run "
|
|
"settings save would change the pair's meaning between two files "
|
|
"(phase 184's read-once discipline)"
|
|
)
|
|
read_at = src.index("llm.settings.sync_pending_count")
|
|
loop_at = src.index("for root in sources:")
|
|
assert read_at < loop_at, "the switch must be read BEFORE the source loop"
|
|
|
|
assert "await asyncio.to_thread(\n _prescan_pending" in src, (
|
|
"the pre-scan is no longer entered through asyncio.to_thread — a long "
|
|
"scan would park the event loop (the phase-179 rule, "
|
|
"test_sync_git_freeze.py)"
|
|
)
|
|
# The scan itself is a plain sync function (it runs in the worker).
|
|
assert not inspect.iscoroutinefunction(importer._prescan_pending)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The scalar statement's shape (the phase-185 column-list style)
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_scalar_statement_projects_exactly_file_hash() -> None:
|
|
"""The pre-scan reads ONE column, so it loads no entity and keeps nothing.
|
|
|
|
The compiled projection names ``documents.file_hash`` and no other
|
|
``documents`` column; the statement resolves to exactly one scalar
|
|
column (``column_descriptions``), so ``session.scalar`` returns a string
|
|
and no ORM object exists to land in an identity map. ``_doc_row_stmt``
|
|
stays the importer's only ``(source, path)`` ROW load (phase 185).
|
|
"""
|
|
compiled = str(
|
|
_pending_hash_stmt("the-source", "notes/a.md").compile(
|
|
dialect=postgresql.dialect()
|
|
)
|
|
)
|
|
projection = re.split(r"\bFROM\b", compiled, maxsplit=1)[0]
|
|
columns = set(re.findall(r"documents\.([a-z_]+)", projection))
|
|
|
|
assert columns == {"file_hash"}, (
|
|
f"the pre-scan statement projects {sorted(columns)} — it must project "
|
|
"documents.file_hash and nothing else (one light metadata read per "
|
|
"file, no entity hydration)"
|
|
)
|
|
# The predicate is the unique (source, path) constraint, like the fast path.
|
|
predicate = re.split(r"\bFROM\b", compiled, maxsplit=1)[1]
|
|
assert "documents.source" in predicate and "documents.path" in predicate
|
|
|
|
stmt = _pending_hash_stmt("s", "p")
|
|
assert len(stmt.column_descriptions) == 1
|
|
assert stmt.column_descriptions[0]["type"] == Document.file_hash.type
|
|
assert "Document" not in {
|
|
str(desc.get("entity")) for desc in stmt.column_descriptions
|
|
}, "the pre-scan statement selects an ENTITY — it must select the column"
|
|
|
|
# The span is the only consumer, and the fast path's row load is intact.
|
|
span_src = inspect.getsource(importer._prescan_pending)
|
|
assert "_pending_hash_stmt(" in span_src
|
|
assert "select(Document)" not in span_src
|
|
assert "defer(" not in span_src and "load_only(" not in span_src, (
|
|
"the pre-scan picked up a loader option — there is no ROW to defer: "
|
|
"it selects one column (phase 185 keeps _doc_row_stmt as the only "
|
|
"row load)"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The pending predicate
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_pending_predicate_fresh_unchanged_changed_and_null_row(
|
|
db: Session, tmp_path: Path
|
|
) -> None:
|
|
"""The four stored-hash states, and the position the set assigns.
|
|
|
|
* no row at all (a first sync) → pending;
|
|
* a row whose ``file_hash`` equals the file's digest → NOT pending;
|
|
* a row whose ``file_hash`` differs → pending;
|
|
* a pre-phase-147 row (``file_hash`` NULL) → pending — the same call the
|
|
fast path makes, so the prediction and the loop agree and the loop
|
|
stamps the hash on the way through.
|
|
|
|
The positions are the 1-based walk order of the PREDICTED files only,
|
|
and the scan total is every importable file (3 here, 3 predicted).
|
|
"""
|
|
root = _root(
|
|
tmp_path,
|
|
"phase194-predicate",
|
|
{
|
|
"fresh.md": "# Fresh\n\nnever indexed\n",
|
|
"same.md": "# Same\n\nindexed as-is\n",
|
|
"moved.md": "# Moved\n\nindexed differently now\n",
|
|
},
|
|
)
|
|
from app.rag.importer import _file_hash
|
|
|
|
_seed_row(db, root.name, "same.md", file_hash=_file_hash(root / "same.md"))
|
|
_seed_row(db, root.name, "moved.md", file_hash="0" * 64)
|
|
db.expunge_all()
|
|
|
|
scan_total, pending = _prescan(root, session=db)
|
|
|
|
assert scan_total == 3
|
|
assert pending == {
|
|
(root.name, "fresh.md"): 1,
|
|
(root.name, "moved.md"): 2,
|
|
}, (
|
|
"the pending set is wrong — no row and a NULL row are pending, a "
|
|
"digest match is not, a mismatch is (the fast path's own predicate)"
|
|
)
|
|
# The scan reads metadata only: nothing was hydrated into the session.
|
|
assert len(db.identity_map) == 0
|
|
|
|
|
|
def test_pending_predicate_ignores_what_the_walk_ignores(
|
|
db: Session, tmp_path: Path
|
|
) -> None:
|
|
"""A file the ignore rule hides is never walked, so it is never pending.
|
|
|
|
The scan uses the EXACT per-root ``ignore`` / ``include_hidden`` rules of
|
|
the processing loop: a hidden dot-directory is out by default and in when
|
|
the root's flag admits it, and an ignored prefix is out even with the flag
|
|
on. A predicted file the loop can never see would be a count that never
|
|
reaches zero, so this is the parity edge, not a nicety.
|
|
"""
|
|
root = _root(
|
|
tmp_path,
|
|
"phase194-ignore",
|
|
{
|
|
"keep.md": "# Keep\n\nbody\n",
|
|
"skipme/hidden-by-rule.md": "# Skip\n\nbody\n",
|
|
".dot/secret.md": "# Dot\n\nbody\n",
|
|
},
|
|
)
|
|
ignore = {str(root): ["skipme"]}
|
|
|
|
scan_total, pending = _prescan(root, session=db, ignore_by_root=ignore)
|
|
assert scan_total == 1
|
|
assert list(pending) == [(root.name, "keep.md")]
|
|
|
|
# With the hidden-folders flag ON the dot-dir file joins the walk (and
|
|
# the scan total with it) — still no pending entry for the ignored one.
|
|
scan_total, pending = _prescan(
|
|
root,
|
|
session=db,
|
|
ignore_by_root=ignore,
|
|
include_hidden_by_root={str(root): True},
|
|
)
|
|
assert scan_total == 2
|
|
assert sorted(pending) == [
|
|
(root.name, ".dot/secret.md"),
|
|
(root.name, "keep.md"),
|
|
]
|
|
|
|
|
|
def test_unreadable_file_is_predicted_changed_and_the_scan_survives(
|
|
db: Session,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A file the digest cannot reach is a PREDICTION of change, not a failure.
|
|
|
|
The window is the phase's own: the scan walks the tree, then hashes it
|
|
seconds later, and a live source can lose a file in between. Failing the
|
|
whole sync for that would be the worse bug — the pre-scan only informs a
|
|
label — so the file lands in the pending set (the loop's own walk decides
|
|
whether it still exists at all) and one warning names it.
|
|
"""
|
|
root = _root(
|
|
tmp_path,
|
|
"phase194-unreadable",
|
|
{"a.md": "# A\n\na\n", "b.md": "# B\n\nb\n"},
|
|
)
|
|
real_hash = importer._file_hash
|
|
|
|
def flaky(path: Path) -> str:
|
|
if path.name == "b.md":
|
|
raise OSError(2, "No such file or directory", str(path))
|
|
return real_hash(path)
|
|
|
|
monkeypatch.setattr(importer, "_file_hash", flaky)
|
|
with caplog.at_level("WARNING", logger="app.importer"):
|
|
scan_total, pending = _prescan(root, session=db)
|
|
|
|
assert scan_total == 2
|
|
assert sorted(pending) == [
|
|
(root.name, "a.md"),
|
|
(root.name, "b.md"),
|
|
]
|
|
lines = [r.getMessage() for r in caplog.records if r.name == "app.importer"]
|
|
assert any(
|
|
line.startswith("import: pre-scan unreadable")
|
|
and f"source={root.name}" in line
|
|
and "path=b.md" in line
|
|
for line in lines
|
|
), f"the skipped file was not named in a warning: {lines}"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# on_scan_progress + the empty KB
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_scan_progress_reports_every_file_across_roots(
|
|
db: Session, tmp_path: Path
|
|
) -> None:
|
|
"""The scan pair is ``(1-based position, combined importable total)``.
|
|
|
|
The denominator is the number the phase-64 stats pre-walk used to
|
|
compute (every importable file, across every root, in *sources* order),
|
|
because the label is about the SCAN — how far the prediction has got —
|
|
and the events cover every file, changed or not.
|
|
"""
|
|
root_a = _root(
|
|
tmp_path, "phase194-scan-a", {"a1.md": "# A1\n\na\n", "a2.md": "# A2\n\nb\n"}
|
|
)
|
|
root_b = _root(tmp_path, "phase194-scan-b", {"sub/b1.md": "# B1\n\nc\n"})
|
|
events: list[tuple[int, int]] = []
|
|
|
|
scan_total, pending = _prescan(
|
|
root_a, root_b, session=db, on_scan_progress=lambda p, t: events.append((p, t))
|
|
)
|
|
|
|
assert scan_total == 3
|
|
assert events == [(1, 3), (2, 3), (3, 3)]
|
|
assert len(pending) == 3
|
|
|
|
|
|
def test_scan_progress_reports_nothing_for_an_empty_kb(
|
|
db: Session, tmp_path: Path
|
|
) -> None:
|
|
"""The degenerate KB: scan total 0, no callback, empty pending set.
|
|
|
|
The phase-64 ``total === 0`` contract, extended to the scan: with nothing
|
|
importable there is no position to report, so the UI's scan label renders
|
|
bare (``Scanning…``, no count part) instead of ``0/0``.
|
|
"""
|
|
empty = _root(tmp_path, "phase194-empty", {})
|
|
missing = tmp_path / "phase194-not-a-dir"
|
|
events: list[tuple[int, int]] = []
|
|
|
|
scan_total, pending = _prescan(
|
|
empty, missing, session=db, on_scan_progress=lambda p, t: events.append((p, t))
|
|
)
|
|
|
|
assert scan_total == 0
|
|
assert pending == {}
|
|
assert events == []
|
|
|
|
|
|
def test_scan_hook_is_called_from_the_worker_thread(
|
|
tmp_path: Path, db: Session
|
|
) -> None:
|
|
"""The documented threading contract of ``on_scan_progress``.
|
|
|
|
The span runs in an ``asyncio.to_thread`` worker, so the hook fires on
|
|
THAT thread — which is only safe because the house hook rule is "assign a
|
|
field", a single GIL-atomic store. The pin records the thread identity
|
|
from inside the callback and from the awaiting coroutine.
|
|
"""
|
|
root = _root(tmp_path, "phase194-thread", {"a.md": "# A\n\na\n"})
|
|
seen: dict[str, int] = {}
|
|
|
|
def hook(position: int, total: int) -> None: # noqa: ARG001
|
|
seen["scan"] = threading.get_ident()
|
|
|
|
async def main() -> None:
|
|
seen["loop"] = threading.get_ident()
|
|
await asyncio.to_thread(
|
|
importer._prescan_pending,
|
|
[root],
|
|
_settings().import_extension_set,
|
|
frozenset(),
|
|
None,
|
|
None,
|
|
hook,
|
|
)
|
|
|
|
asyncio.run(main())
|
|
|
|
assert seen["scan"] != seen["loop"], (
|
|
"the pre-scan hook ran on the event-loop thread — the span must live "
|
|
"in a worker (phase 179)"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# done = the position in the pending set
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_done_is_the_pending_position_through_import_sources(
|
|
db: Session, tmp_path: Path
|
|
) -> None:
|
|
"""3 importable files, 2 of them pending: the pair is ``1/2``, ``1/2``,
|
|
``2/2``.
|
|
|
|
``stable.md`` is indexed and unchanged, so the scan does not predict it
|
|
and the walk moving past it does NOT advance the count — the label holds
|
|
its position (this is the shape the owner's prod case takes: the count
|
|
stops climbing across a run of unchanged files). The two changed files
|
|
advance it in walk order, and the denominator is the pending set's size,
|
|
never the 3-file walk.
|
|
"""
|
|
root = _root(
|
|
tmp_path,
|
|
"phase194-position",
|
|
{
|
|
"a.md": "# A\n\nalpha\n",
|
|
"stable.md": "# Stable\n\nunchanged\n",
|
|
"z.md": "# Z\n\nzulu\n",
|
|
},
|
|
)
|
|
llm = FakeEmbedder()
|
|
events: list[tuple[str, str, int, int]] = []
|
|
try:
|
|
# Index everything, then change two files.
|
|
asyncio.run(import_sources([root], llm, session=db))
|
|
(root / "a.md").write_text("# A\n\nalpha edited\n", encoding="utf-8")
|
|
(root / "z.md").write_text("# Z\n\nzulu edited\n", encoding="utf-8")
|
|
|
|
events.clear()
|
|
summary = asyncio.run(
|
|
import_sources(
|
|
[root],
|
|
llm,
|
|
session=db,
|
|
progress=lambda s, r, d, t: events.append((s, r, d, t)),
|
|
)
|
|
)
|
|
|
|
assert (summary.files, summary.updated, summary.unchanged) == (3, 2, 1)
|
|
assert events == [
|
|
(root.name, "a.md", 1, 2),
|
|
(root.name, "stable.md", 1, 2), # not predicted: the position holds
|
|
(root.name, "z.md", 2, 2),
|
|
]
|
|
# The parity the UI depends on: the denominator IS the work.
|
|
assert summary.updated + summary.added == events[-1][3]
|
|
finally:
|
|
db.execute(text("DELETE FROM documents WHERE source = :s"), {"s": root.name})
|
|
db.commit()
|
|
|
|
|
|
def test_empty_kb_reports_no_count_part_through_import_sources(
|
|
db: Session, tmp_path: Path
|
|
) -> None:
|
|
"""An empty source with the hook on: no file, no pair, ``files_total`` 0.
|
|
|
|
The import is a no-op and the hook never fires, so the status keeps the
|
|
zero denominator the UI's ``total === 0`` guard hides the count part for
|
|
(the phase-64 contract, arriving through the pre-scan path rather than
|
|
the stats pre-walk).
|
|
"""
|
|
root = _root(tmp_path, "phase194-empty-run", {})
|
|
events: list[tuple[str, str, int, int]] = []
|
|
scan_events: list[tuple[int, int]] = []
|
|
|
|
summary = asyncio.run(
|
|
import_sources(
|
|
[root],
|
|
FakeEmbedder(),
|
|
session=db,
|
|
progress=lambda s, r, d, t: events.append((s, r, d, t)),
|
|
on_scan_progress=lambda p, t: scan_events.append((p, t)),
|
|
)
|
|
)
|
|
|
|
assert (summary.files, summary.added, summary.errors) == (0, 0, 0)
|
|
assert events == []
|
|
assert scan_events == []
|
|
|
|
|
|
def test_cli_caller_is_untouched_by_the_pending_semantics(
|
|
db: Session, tmp_path: Path
|
|
) -> None:
|
|
"""The CLI-shaped run (no hook at all) on an unchanged KB is the pre-phase
|
|
run: one file processed, nothing counted, one walk.
|
|
|
|
The byte-identity of the non-UI caller is the phase's cheapest promise —
|
|
``scripts/import_docs.py`` passes no ``progress``, so it neither pays the
|
|
scan nor sees its numbers.
|
|
"""
|
|
root = _root(tmp_path, "phase194-cli", {"a.md": "# A\n\na\n"})
|
|
llm = FakeEmbedder()
|
|
try:
|
|
first = asyncio.run(import_sources([root], llm, session=db))
|
|
assert (first.files, first.added) == (1, 1)
|
|
second = asyncio.run(import_sources([root], llm, session=db))
|
|
assert (second.files, second.added, second.unchanged) == (1, 0, 1)
|
|
finally:
|
|
db.execute(text("DELETE FROM documents WHERE source = :s"), {"s": root.name})
|
|
db.commit()
|