Skip to content

feat(collections): Phase 1 docs collections (create/index/link/grants, wires loaders + gitignore walker) - #195

Merged
jaylfc merged 17 commits into
masterfrom
feat/collections-phase1
Jul 20, 2026
Merged

feat(collections): Phase 1 docs collections (create/index/link/grants, wires loaders + gitignore walker)#195
jaylfc merged 17 commits into
masterfrom
feat/collections-phase1

Conversation

@jaylfc

@jaylfc jaylfc commented Jul 19, 2026

Copy link
Copy Markdown
Owner

What

Phase 1 of collections (the docs MVP from docs/specs/codebase-indexing-collections-design.md): named containers of content indexed from a folder, queryable by granted agents alongside conversation memory. This wires the previously-unwired loader framework (taosmd/loaders/) into a real ingest path and adds the full container lifecycle, access control, and query surface. HELD FOR REVIEW: do not merge without sign-off.

Why

Codebase indexing is table stakes for coding agents; docs collections ship first (per the Jul 19 decision) to get real usage on the container model before the Phase 2 code path. Everything is additive and off by default: with no collections.allowed_roots configured, nothing changes for existing installs.

Binding decisions implemented (Jul 19)

  • No tree-sitter, no new dependencies; docs-shaped files only (DocLoader md/txt/markdown/rst + other registered loaders where they claim a file); zero-dep paragraph chunker with # upgrade-path: comments.
  • Admin-token gating (fail closed) on POST /collections and POST /collections/{id}/index; DELETE /collections/{id} (archive) is admin too. Data plane (list/get/link/unlink/grants/query) uses the normal token + grants.
  • Per-collection embedder field: stored at create, returned in GET, read by the index path; Phase 1 always indexes with the global default.
  • Grants table (canonical_id, scope='collection', collection_id) UNIQUE together, stored in taosmd, enforced on search per requesting agent; no transitivity (project links never grant access).
  • Typed link rows {type: taos|git, id}, multiple per collection, link/unlink metadata-only.
  • Zero-loss: DELETE = archive (reversible status change, content hidden from query, nothing destroyed); re-index supersedes changed/deleted-file rows via the existing valid_to machinery.
  • Filesystem safety: collections.allowed_roots config list (default EMPTY = feature off), resolve_within containment at create and at every index, symlink escapes rejected, check_size per file.

How it works

  • taosmd/collections.py: sqlite store (collections.db) for rows/links/grants + per-file content hashes; gitignore-aware walker (simplified stdlib fnmatch rules, VCS/dependency/hidden dirs, binary sniff, size cap); ingest_folder chunks and routes through api.ingest_batch under the collection id as agent namespace, so per-chunk content-hash ids dedup unchanged files and the archive keeps every version.
  • Content rows live under the collection id (which matches the agent-name grammar by construction), so search scoping is the existing search_agents mechanism: api.search gains collections/collections_only and extends the agent set with granted, non-archived collections only.
  • Indexing over HTTP is async: 202 + {"status": "indexing", "job": id}, poll GET /collections/{id} until ready|error; the walk runs via a new _ServiceLoop.spawn so store access stays on the single service-loop thread and HTTP never blocks.

Endpoints

POST   /collections                      (admin)  create
GET    /collections [?project=]                   list (project matches links of either type)
GET    /collections/{id}                          full row: status/stats/links/grants
POST   /collections/{id}/index           (admin)  202 + poll
POST   /collections/{id}/link|unlink              {"type": "taos"|"git", "id"}
POST   /collections/{id}/grants                   {"agent"}
DELETE /collections/{id}/grants/{agent}           revoke
DELETE /collections/{id}                 (admin)  archive (reversible)
POST/GET /search                                  + collection / collections / collections_only

Plus CLI taosmd collections list|create|index|link|unlink|grant|revoke and MCP memory_list_collections + collection on memory_search.

Tests

  • Full suite green: 1178 passed (1110 before this branch; 68 new tests across config roots, store, walker/chunker/ingest, HTTP, CLI, MCP). The known test_admin_surface random-order flake did not trip.

Eval (pre-registered, section 5 of the spec)

benchmarks/collections_eval.py + a 20-question gold-file set over the repo's own docs/ (tracked in benchmarks/data/collections_eval_questions.json, written before the index was built). No local ONNX model exists on this dev host, so the smoke ran the lexical (BM25) fallback arm, which stubs the embedder only so rows land and retrieves through the engine's mode="bm25" path (full walk/chunk/ingest/grant/scope path exercised, vectors never consulted):

  • Lexical smoke: file-level Recall@5 = 19/20 = 0.950 (47 files / 493 chunks indexed in 3.1 s). The one miss is the LoCoMo-scorecards question ("LoCoMo" appears across many docs).
  • The semantic arm (real ONNX embedder) is the number the kill bar (>= 0.8) judges; run --mode semantic on the Fedora bench host.

Spec deviations (flagged)

  1. source_path is required on create (spec body marked it optional). The docs MVP has nothing to do with a source-less collection; making it required keeps create from minting rows index must then reject.
  2. Create takes no project_id body field; project attachment is exclusively via the typed link endpoint (one mechanism instead of two, matches the typed-links decision).
  3. A fourth table, collection_files (per-file content hashes), backs incremental re-index; storing the hash map in stats JSON would have made stats writes racy with indexing.
  4. Statuses are created|indexing|ready|error|archived (decisions list) rather than the spec section-2 empty|... wording.
  5. Service-layer collections ops are local-only (no RemoteClient forwarding), matching the shelf admin wrappers: the server that owns the indexed filesystem runs the ops. Remote-configured CLI installs would need a follow-up to proxy them.
  6. api._format_hit now unwraps to the innermost user metadata so the semantic path exposes the same metadata contract as the BM25 path for batch-ingested rows (previously the full path surfaced the row envelope). No test relied on the old double-nested shape.

Docs: docs/collections.md user page, endpoint docstring table + serve() summary, CHANGELOG entry, spec section 9 (Decisions 2026-07-19).

Summary by CodeRabbit

  • New Features
    • Added Collections (Phase 1) to index/search approved documentation folders with collection-scoped querying and per-collection access via links and per-agent grants.
    • Added collections management via HTTP, CLI, and MCP (including listing, linking/unlinking, granting/revoking, indexing, and reversible archiving).
    • Added incremental indexing with deduplication, allowed-root enforcement, and safe handling of symlink escapes; supports async indexing with status polling.
    • Added a per-collection embedder field (Phase 1 uses the global default).
  • Documentation
    • Added a Collections user guide and Phase 1 design decisions; updated Unreleased changelog.
  • Tests
    • Added comprehensive end-to-end coverage for collections store, ingestion behavior, config, HTTP/CLI/MCP, grants/search enforcement, and indexing semantics.
  • Chores
    • Updated ignore rules to explicitly track the collections evaluation question set.

jaylfc added 7 commits July 19, 2026 23:57
…t off)

The allowed-roots list gates the whole collections feature: a collection
source_path must resolve inside one of these directories or create/index
are refused. Default is empty (feature off) so nothing changes for
existing installs. TAOSMD_COLLECTIONS_ALLOWED_ROOTS overrides the config
file, matching the other server settings.
First-class collection rows in collections.db with the lifecycle
created|indexing|ready|error|archived, plus three side tables:
typed project links {taos|git, id} (metadata only, no transitivity),
grants (canonical_id, scope='collection', collection_id) UNIQUE
together, and per-file content hashes for incremental re-index.
source_path is validated against collections.allowed_roots via
resolve_within at create time. Delete is an archive alias: reversible
status change, nothing destroyed. Collection ids (col- + 12 hex) match
the agent-name grammar so content rows can live under the existing
per-agent scoping.
…arch scoping

collect_files walks a collection source with simplified stdlib gitignore
rules (nested files, negation, dir-only, anchored patterns), skips
VCS/dependency/hidden dirs, binary files (extension + null-byte sniff),
oversized files, symlink escapes (resolve_within), and anything no
registered loader explicitly claims - wiring the previously-unwired
loader registry into a real ingest path.

ingest_folder chunks each doc (zero-dep paragraph packer, upgrade-path
comment for structure-aware chunking) and routes chunks through
api.ingest_batch under the collection id as agent namespace with
per-chunk content-hash ids, so re-index dedups unchanged files for free.
Changed and deleted files get their old rows soft-superseded (valid_to +
collection-reindex marker), never deleted; the archive keeps every
version. Status runs created -> indexing -> ready|error with stats and
last_indexed on the row; allowed_roots is re-checked at every index.

api.search gains collections/collections_only: granted collections join
search_agents (grants enforced per requesting agent, archived or
ungranted collections contribute nothing), and collection hits carry
collection_id/file_path/source metadata.
…x, async 202 poll)

Service layer gains local-only collections_* wrappers (like the shelf
admin wrappers: the server that owns the indexed filesystem runs the
ops). HTTP adds the full contract from the design spec:

- POST /collections and POST /collections/{id}/index are admin-token
  gated (fail closed), joining the shelves routes in _is_admin_route
- DELETE /collections/{id} (admin) archives reversibly; do_DELETE is new
- data plane: GET /collections[?project=], GET /collections/{id},
  POST link/unlink (typed {taos|git, id}), POST grants,
  DELETE /collections/{id}/grants/{agent}
- indexing is async: 202 + {status: indexing, job}, poll the GET until
  ready|error; the walk runs via a new _ServiceLoop.spawn so all store
  access stays on the single service-loop thread and HTTP never blocks
- GET/POST /search gain collection/collections/collections_only params
  with per-agent grant enforcement

Endpoint docstring table and serve() startup summary updated.
CLI: taosmd collections list|create|index|link|unlink|grant|revoke in
the shelves/tasks subcommand style; index runs synchronously and prints
the final stats (the async 202 path is HTTP-only).

MCP: memory_list_collections tool and a collection parameter on
memory_search (grant-gated, same as the HTTP surface).

api._format_hit now unwraps to the innermost user metadata so the full
retrieval path exposes the same metadata contract as the BM25 path for
batch-ingested rows - collection hits carry file_path/source/
collection_id on both paths.
docs/collections.md covers enabling allowed_roots, the lifecycle, query
semantics with grants, the HTTP surface split (admin vs data plane), and
the zero-loss guarantees. The spec gains section 9 recording the Jul 19
decisions: no tree-sitter/new deps, admin gating on create+index, ship
Phase 1 alone, per-collection embedder mechanism now (global default
used), taOS owns the panel, plus the typed-links/grants verdict.
… docs

20 questions with gold file paths (written before the index was built)
over the repo's own docs/ folder, per section 5 of the design spec.
Metric is judge-free file-level Recall@5. Two arms: semantic (real ONNX
embedder; the arm the kill bar judges, run on the bench host) and a
lexical fallback for hosts with no local ONNX model, which stubs the
embedder so rows land and retrieves through the engine's BM25-only mode,
exercising the full walk/chunk/ingest/grant/scope path. auto picks per
host. Lexical smoke on this host: 47 files / 493 chunks indexed in
3.1s, Recall@5 = 19/20 = 0.950 (one miss: the LoCoMo-scorecards
question, cross-file term collision on 'LoCoMo').

The question set is a tiny pre-registration file, exempted from the
benchmarks/data size gitignore like the README pointer.
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jaylfc, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 49 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a84affee-b6b1-47e8-b2e7-7d1bf6700209

📥 Commits

Reviewing files that changed from the base of the PR and between b63277e and 5498771.

📒 Files selected for processing (6)
  • docs/collections.md
  • docs/specs/codebase-indexing-collections-design.md
  • taosmd/cli.py
  • taosmd/collections.py
  • taosmd/http_server.py
  • tests/test_collections_ingest.py
📝 Walkthrough

Walkthrough

Adds filesystem-backed Collections with safe-root configuration, incremental indexing, grant-scoped search, HTTP/CLI/MCP interfaces, archival semantics, and a Recall@k evaluation benchmark with comprehensive tests.

Changes

Collections indexing and storage

Layer / File(s) Summary
Collection storage and indexing
taosmd/collections.py, taosmd/config.py, tests/test_collections_*, tests/test_config_collections_roots.py
Adds collection persistence, allowed-root validation, filesystem discovery, chunking, incremental ingestion, soft superseding, archival state, and related tests.

Search and service integration

Layer / File(s) Summary
Search scoping and HTTP orchestration
taosmd/api.py, taosmd/http_server.py, taosmd/service.py, tests/test_collections_http.py, tests/test_api.py
Adds grant-filtered collection search, metadata normalization, HTTP routes, admin authorization, asynchronous indexing, archive handling, service wrappers, and integration coverage.

Client interfaces

Layer / File(s) Summary
CLI and MCP interfaces
taosmd/cli.py, taosmd/mcp_server.py, tests/test_collections_cli.py, tests/test_collections_mcp.py
Adds collection lifecycle CLI commands and MCP tools for collection listing and scoped search.

Evaluation and documentation

Layer / File(s) Summary
Evaluation and feature documentation
benchmarks/*, docs/collections.md, docs/specs/*, CHANGELOG.md, .gitignore
Adds the pre-registered Recall@k benchmark, collections documentation and design decisions, changelog coverage, and tracking for the benchmark question set.

Estimated code review effort: 5 (Critical) | ~90 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant HTTPServer
  participant Service
  participant CollectionStore
  participant Search
  Operator->>HTTPServer: Create and index collection
  HTTPServer->>Service: Start collection indexing
  Service->>CollectionStore: Validate source and update status
  Service->>CollectionStore: Store indexed file state
  Operator->>HTTPServer: Search with collection scope
  HTTPServer->>Search: Forward collection ids and agent
  Search->>CollectionStore: Check collection grant
  Search-->>HTTPServer: Return authorized collection hits
  HTTPServer-->>Operator: Return search response
Loading

Possibly related PRs

  • jaylfc/taosmd#60: Overlaps with hit formatting and the search() API surface modified for collection metadata and scoping.
  • jaylfc/taosmd#163: Touches the same search() and hit-processing paths through claims gating.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: Phase 1 collections with create/index/link/grants plus loader and gitignore-walker wiring.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/collections-phase1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gitar-bot

gitar-bot Bot commented Jul 19, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

Comment thread taosmd/collections.py
ts = time.time()
marker = f"collection-reindex:{ts}"
rows = vmem._conn.execute(
"SELECT id, metadata_json FROM vector_memory WHERE valid_to IS NULL"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: _supersede_collection_rows scans the entire vector_memory table (no agent/collection_id SQL filter) on every changed/deleted file, building metadata_json for each row and decoding it in Python.

Per re-index this is O(total_active_rows × changed_files). ingest_folder calls it once per entry in [*changed, *deleted] (collections.py:761), so a re-index of a collection with C changed files issues C full-table scans over the whole vector store. As the store grows (conversation memory from all agents lives in the same table), indexing time scales with total store size, not the collection's size. Filter the scan in SQL — at minimum restrict to rows whose agent namespace is the collection id, or add a column/index keyed on collection_id.

Comment thread taosmd/collections.py
skips["skipped_size"] += 1
continue
except OSError:
skips["skipped_symlink"] += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: This except OSError catches failures from check_size (a stat/open call), not symlink escapes, yet it increments skipped_symlink. A permission error or other I/O failure on a legitimate in-root file would be silently reported as a symlink skip, masking the real cause and corrupting the skips accounting surfaced in stats.

Use a distinct skip reason (e.g. skipped_error), keeping skipped_symlink only for the resolve_within failure above.

Comment thread taosmd/collections.py
with open(fpath, "rb") as fh:
head = fh.read(1024)
except OSError:
skips["skipped_symlink"] += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Same mislabel as the check_size OSError above: a failure to open() the file (permission denied, etc.) is counted as skipped_symlink. Use a distinct skip reason so the stats accurately reflect why a file was dropped.

Comment thread taosmd/service.py
f"collection {collection_id!r} is archived; unarchive before indexing"
)
store.resolve_source_path(col["source_path"])
store.set_status(collection_id, "indexing")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: collections_index_start validates, sets status to indexing, and returns; the actual walk is spawned separately as a background job that re-opens a fresh CollectionStore and re-validates. Between the 202 response and the background task starting, the collection is marked indexing with no in-flight guard. Two near-simultaneous POST /collections/{id}/index calls (both admin-gated) can each pass the archived/resolve checks and spawn overlapping ingest_folder jobs for the same collection, racing on ingest_batch/set_file_state/_supersede_collection_rows and producing double-supersede or duplicated stats.

Consider holding a per-collection in-memory lock, or having ingest_folder refuse to start when status is already indexing, so a second concurrent index is rejected rather than run in parallel.

@kilo-code-bot

kilo-code-bot Bot commented Jul 19, 2026

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found (incremental) | Recommendation: Merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 0
Incremental Update (since b63277e)
  • NEW: 0 new issues across the 6 changed files (taosmd/collections.py, taosmd/cli.py, taosmd/http_server.py, docs/collections.md, docs/specs/codebase-indexing-collections-design.md, tests/test_collections_ingest.py).
  • Verified: The incremental change splits the old unified files_deleted counter into files_deleted (gone from disk) and files_emptied (still present but empty). Logic is internally consistent: emptied is a set (dedup-safe); removed = [rel for rel in retired if rel not in emptied] produces disjoint sets; the supersede loop iterates [*changed, *retired] so both classes retire their rows identically; files_total is recomputed after remove_file_state so it reflects only currently-tracked files; files_emptied is always present (defaults to 0).
  • Tests: New and updated tests cover the no-op empty-file case, the deleted-only case, the emptied-only case, and the combined emptied+deleted case in one pass, plus zero-loss / supersede-history assertions. All branches exercised.
  • Prior findings (lines 664, 578, 587, 930, 843, 787) are on unchanged lines and outside this increment's scope; not carried forward per incremental rules.
Files Reviewed (6 changed)
  • taosmd/collections.py - incremental split clean, no new defects
  • taosmd/cli.py - new stat printed, no issues
  • taosmd/http_server.py - docs endpoint table updated, no issues
  • docs/collections.md - doc additions, no issues
  • docs/specs/codebase-indexing-collections-design.md - spec wording, no issues
  • tests/test_collections_ingest.py - new tests reviewed, no issues
Previous Review Summaries (3 snapshots, latest commit b63277e)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit b63277e)

Status: 5 Issues Found (1 resolved this increment; 0 new) | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 4
Issue Details (click to expand)

WARNING

File Line Issue
taosmd/collections.py 664 _supersede_collection_rows scans the entire vector_memory table per changed/deleted file (O(total_rows x changed_files)); no SQL filter by agent/collection, so re-index cost scales with whole-store size

SUGGESTION

File Line Issue
taosmd/collections.py 578 check_size OSError (permission/IO) miscounted as skipped_symlink, masking real failure cause
taosmd/collections.py 587 open() OSError miscounted as skipped_symlink, same mislabel
taosmd/collections.py 843 Stability/availability: ingest_folder catches all exceptions and stores error status then re-raises; surface-level error string only
taosmd/collections.py 787 Functional correctness: re-index supersede logic minor edge case on chunk dedup
Incremental Update (since 5edf175)
  • RESOLVED: service.py concurrent-index guard (CollectionBusyError + status == "indexing" check, 409 handler in http_server.py) — no longer active.
  • NEW: 0 new issues across the 3 changed files (taosmd/collections.py, docs/collections.md, tests/test_collections_ingest.py).
  • Verified: The incremental change adds a finally: store.close() to fix the per-run sqlite handle leak, and seen.discard(rel) for blank/emptied files so stale rows correctly route through the deleted supersede path. Both are covered by new tests and introduce no new defects.
Files Reviewed (3 changed)
  • taosmd/collections.py - prior findings still active; new close/blank handling clean
  • docs/collections.md - 0 issues (language hint only)
  • tests/test_collections_ingest.py - 0 issues (new tests reviewed)

Previous review (commit 5edf175)

Status: 5 Issues Found (1 resolved in this increment) | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 4
Issue Details (click to expand)

WARNING

File Line Issue
taosmd/collections.py 664 _supersede_collection_rows scans the entire vector_memory table per changed/deleted file (O(total_rows x changed_files)); no SQL filter by agent/collection, so re-index cost scales with whole-store size

SUGGESTION

File Line Issue
taosmd/collections.py 578 check_size OSError (permission/IO) miscounted as skipped_symlink, masking real failure cause
taosmd/collections.py 587 open() OSError miscounted as skipped_symlink, same mislabel
taosmd/collections.py 843 Stability/availability: ingest_folder catches all exceptions and stores error status then re-raises; re-raise path is fine but surface-level error string only
taosmd/collections.py 787 Functional correctness: re-index supersede logic minor edge case on chunk dedup

Incremental Update (since 42ab4ad)

  • RESOLVED: service.py concurrent-index guard (CollectionBusyError + status == "indexing" check at lines 919-925, plus 409 handler in http_server.py) — the previously-flagged service.py:930 suggestion is now fixed.
  • No NEW issues found in the 8 changed files. New code (_format_hit provenance preservation, max_files tree cap, _hash_and_chunk, off-loop asyncio.to_thread threading, 409 wiring) is consistent and matches its tests.
Files Reviewed (8 changed)
  • docs/collections.md - 0 issues
  • taosmd/api.py - 0 new issues (_format_hit provenance fix verified)
  • taosmd/collections.py - prior findings still active; new cap/hash/threading code clean
  • taosmd/http_server.py - 0 new issues (409 handler)
  • taosmd/service.py - prior finding RESOLVED
  • tests/test_api.py, tests/test_collections_http.py, tests/test_collections_ingest.py - test additions reviewed

Previous review (commit 42ab4ad)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 3
Issue Details (click to expand)

WARNING

File Line Issue
taosmd/collections.py 639 _supersede_collection_rows scans the entire vector_memory table per changed/deleted file (O(total_rows × changed_files)); no SQL filter by agent/collection, so re-index cost scales with whole-store size

SUGGESTION

File Line Issue
taosmd/collections.py 559 check_size OSError (permission/IO) miscounted as skipped_symlink, masking real failure cause
taosmd/collections.py 568 open() OSError miscounted as skipped_symlink, same mislabel
taosmd/service.py 923 Concurrent POST /collections/{id}/index can spawn overlapping background jobs (no in-flight guard once status set to indexing)
Files Reviewed (8 source files + tests)
  • taosmd/collections.py
  • taosmd/api.py
  • taosmd/service.py
  • taosmd/http_server.py
  • taosmd/cli.py
  • taosmd/mcp_server.py
  • taosmd/config.py
  • benchmarks/collections_eval.py
  • (docs, specs, changelog, and test files reviewed for context)

Fix these issues in Kilo Cloud


Reviewed by hy3:free · Input: 28.4K · Output: 1.6K · Cached: 126.7K

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
docs/collections.md (1)

79-95: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add language specifiers to fenced code blocks.

To improve syntax highlighting and resolve markdownlint warnings (MD040), specify a language (e.g., text or http) for these fenced code blocks.

📝 Proposed fix
-```
+```text
 GET    /collections [?project=<id>]
 GET    /collections/{id}
 POST   /collections/{id}/link      {"type": "taos"|"git", "id": "..."}
 POST   /collections/{id}/unlink    same body
 POST   /collections/{id}/grants    {"agent": "..."}
 DELETE /collections/{id}/grants/{agent}
 POST   /search                     with collection/collections/collections_only

Admin (dedicated admin token, fail-closed):

- +text
POST /collections {"name", "kind", "source_path", "embedder"?}
POST /collections/{id}/index -> 202; poll GET /collections/{id}
DELETE /collections/{id} -> archive (reversible)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/collections.md` around lines 79 - 95, Update both fenced code blocks in
the collections API documentation to include a language specifier, using text or
another appropriate plain-text language, while preserving their contents and
formatting.

Source: Linters/SAST tools

taosmd/collections.py (1)

638-640: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Narrow the supersede query by agent and file path _supersede_collection_rows() still scans every active row and JSON-parses them in Python for each changed/deleted file. Filter in SQL on json_extract(metadata_json, '$.agent') = ? and json_extract(metadata_json, '$.metadata.file_path') = ? so re-index only touches this collection’s rows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@taosmd/collections.py` around lines 638 - 640, Update
_supersede_collection_rows() so its active-row SQL query filters by the
collection’s agent and metadata.file_path using json_extract(metadata_json,
'$.agent') and json_extract(metadata_json, '$.metadata.file_path'), binding both
values as query parameters before fetchall(). Preserve the existing row
processing while limiting re-indexing to this collection’s rows.
tests/test_collections_ingest.py (1)

72-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor: silence the unused unpacked bindings flagged by Ruff (RUF059).

skips (Lines 73, 137) and store (Line 218) are never used in those tests. Prefix with _ to keep the lint clean.

Also applies to: 133-137, 216-218

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_collections_ingest.py` around lines 72 - 73, Rename the unused
unpacked bindings in the affected tests: change skips in
test_collect_files_finds_claimed_docs and the corresponding test around lines
133–137 to _skips, and change store in the test around lines 216–218 to _store,
preserving the existing unpacking behavior.

Source: Linters/SAST tools

taosmd/api.py (1)

434-436: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Optional: the unwrap loop over-descends if user metadata itself contains a nested metadata dict.

The loop keeps descending while any metadata key is a dict, so a hit whose user metadata legitimately holds a nested dict under "metadata" would return the inner dict and drop the sibling user keys. Not reachable for collection hits (flat metadata), but worth confirming no other producer nests a user-controlled metadata field.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@taosmd/api.py` around lines 434 - 436, Review the metadata producers feeding
user_md and confirm whether nested user-controlled “metadata” dictionaries are
possible. Update the unwrapping logic around user_md so it removes only the
known outer metadata wrapper rather than recursively descending through
legitimate nested user metadata, preserving sibling keys at the user-metadata
level.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@taosmd/collections.py`:
- Around line 692-793: Update ingest_folder around CollectionStore and its
existing try/except so the store is closed on every exit path: retain the
current status/error handling, nest it under an outer try/finally, and call
store.close() in that finally block after successful completion or exceptions.
- Around line 731-737: Update the empty-text branch in the collection indexing
flow around the visible prior hash comparison so previously indexed files are
treated as changed: supersede their active rows and remove their file state
before continuing. Preserve the existing skip behavior for files that were never
indexed, while ensuring an emptied file no longer retains searchable rows or its
stored hash.

---

Nitpick comments:
In `@docs/collections.md`:
- Around line 79-95: Update both fenced code blocks in the collections API
documentation to include a language specifier, using text or another appropriate
plain-text language, while preserving their contents and formatting.

In `@taosmd/api.py`:
- Around line 434-436: Review the metadata producers feeding user_md and confirm
whether nested user-controlled “metadata” dictionaries are possible. Update the
unwrapping logic around user_md so it removes only the known outer metadata
wrapper rather than recursively descending through legitimate nested user
metadata, preserving sibling keys at the user-metadata level.

In `@taosmd/collections.py`:
- Around line 638-640: Update _supersede_collection_rows() so its active-row SQL
query filters by the collection’s agent and metadata.file_path using
json_extract(metadata_json, '$.agent') and json_extract(metadata_json,
'$.metadata.file_path'), binding both values as query parameters before
fetchall(). Preserve the existing row processing while limiting re-indexing to
this collection’s rows.

In `@tests/test_collections_ingest.py`:
- Around line 72-73: Rename the unused unpacked bindings in the affected tests:
change skips in test_collect_files_finds_claimed_docs and the corresponding test
around lines 133–137 to _skips, and change store in the test around lines
216–218 to _store, preserving the existing unpacking behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 17f7b52a-52aa-4a6c-ae59-ed3686fccc55

📥 Commits

Reviewing files that changed from the base of the PR and between f15a7cf and 42ab4ad.

📒 Files selected for processing (19)
  • .gitignore
  • CHANGELOG.md
  • benchmarks/collections_eval.py
  • benchmarks/data/collections_eval_questions.json
  • docs/collections.md
  • docs/specs/codebase-indexing-collections-design.md
  • taosmd/api.py
  • taosmd/cli.py
  • taosmd/collections.py
  • taosmd/config.py
  • taosmd/http_server.py
  • taosmd/mcp_server.py
  • taosmd/service.py
  • tests/test_collections_cli.py
  • tests/test_collections_http.py
  • tests/test_collections_ingest.py
  • tests/test_collections_mcp.py
  • tests/test_collections_store.py
  • tests/test_config_collections_roots.py

Comment thread taosmd/collections.py Outdated
Comment thread taosmd/collections.py Outdated
jaylfc added 5 commits July 20, 2026 05:19
…nwrap

The unwrap-to-innermost loop gave every path the same user-metadata
contract but stripped the provenance envelope on the way down: batch
rows are double-wrapped on the semantic path (retrieval envelope ->
row meta -> user metadata), so archive_span_id vanished from the
formatted hit and the prefer_verified claims gate went blind for those
rows (a contradicted-claim row survived recall).

Capture archive_span_id/agent/project while descending and re-attach
them to a copy of the innermost metadata (user keys never clobbered),
so the gate keeps its inputs and collection hits still expose
file_path. Regression tests cover the semantic and bm25 paths plus the
contradicted-row drop.
The 202+poll index contract still blocked the single service loop:
collect_files did the full os.walk, per-file size checks, and the 1KB
null-byte sniff (opening every candidate file) synchronously on the
loop thread, so /search and /ingest stalled for the duration of an
index. Run the walk and the per-file hash+chunk step through
asyncio.to_thread; the store writes stay on the loop where the
thread-affine sqlite connections live. Test pins the walk to a worker
thread and that the pipeline still lands created -> indexing -> ready.
collections_index_start set status=indexing without checking it, so a
second POST /collections/{id}/index while one index was running would
double-walk the same tree into the batch dedup. Raise
CollectionBusyError when the status is already indexing; the endpoint
maps it to 409 with a poll hint. ready and error both re-arm the
start, so retries after completion or failure keep working.
The walker had a per-file size cap but no tree cap, so a collection
pointed at a huge tree (a home dir, a monorepo root) would grind
through every file. collect_files now raises past DEFAULT_MAX_FILES
(20000, upgrade-path comment for making it configurable) and the index
errors cleanly with the offending path and a pointer to narrow the
source folder; ingest_folder passes the cap through for callers.
Make the Jul 19 data-plane-grants decision explicit rather than a
surprise: grants scope collections per agent inside the server-token
boundary, any holder of the server token can manage grants, and
collection access is exactly as strong as that token. Only
create/index/delete sit behind the admin token because they touch the
server filesystem. Also document the 20000-file walk cap and the 409
on concurrent index starts.
@jaylfc

jaylfc commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review findings addressed. Fixes pushed as five commits, each with a regression test written first and confirmed failing before the fix.

1. Claims gate went blind for batch rows (high) - _format_hit's unwrap-to-innermost loop stripped the provenance envelope on the semantic path (retrieval envelope -> row meta -> user metadata), so archive_span_id vanished from formatted hits and prefer_verified stopped dropping contradicted-claim rows. Fix: capture archive_span_id/agent/project while descending and re-attach them to a copy of the innermost metadata (user keys never clobbered). Collection hits keep exposing file_path. Three regression tests: span survives on the semantic and bm25 paths, and a contradicted batch row is dropped again. (0a24e8c)

2. Async index blocked the service loop (medium) - collect_files (full os.walk + per-file stat + 1KB null sniff) and the per-file hash+chunk step now run via asyncio.to_thread, so /search and /ingest stay responsive during an index; store writes stay on the loop with the thread-affine sqlite connections. Test pins the walk to a worker thread and end-to-end completion. (a30ea3b)

4. No concurrent-index guard (low) - collections_index_start now raises CollectionBusyError when the status is already indexing; the endpoint returns 409 with a poll hint. ready and error re-arm it. Tested at the service and HTTP layers. (93574a3)

5. No tree cap on the walker (low) - collect_files errors past 20000 ingestable files (constant with an upgrade-path note for making it configurable) and the index fails cleanly naming the offending path. (fabfe4c)

3. Grants on the data plane - intended Jul 19 decision, left as is; docs/collections.md now states the trust model explicitly (grants scope collections per agent inside the server-token boundary; any holder of the server token can manage grants; collection access is as strong as the server token). Finding 6's gitignore simplification already carries its upgrade-path marker. (5edf175)

Suite: 1186 passed (was 1178; +8 tests).

@jaylfc

jaylfc commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Semantic eval result (the pre-registered kill-criterion arm), run on the Fedora bench host with the real MiniLM ONNX embedder rather than the lexical fallback:

mode=semantic   file-level Recall@5: 20/20 = 1.000

Protocol as specced: the repo's own docs/ tree indexed into a temp collection, 20 gold-file questions, file-level Recall@5. This clears the ship bar (>= 0.8). For comparison the lexical/BM25 fallback arm scored 0.950 on the same question set; the one miss there was a term collision on the LoCoMo-scorecards question, which the semantic arm resolves.

Ship-bar status for Phase 1:

  • file-level Recall@5 >= 0.8: PASS (1.000 semantic, 0.950 lexical)
  • index the repo docs tree well under 5 min: PASS (47 files / 493 chunks in ~3s)
  • zero-loss verified (re-index supersedes, delete archives, no hard-delete path): PASS (adversarial review confirmed)

Held for review.

jaylfc added 3 commits July 20, 2026 09:41
…es empty

A file whose content was emptied still exists on disk, so it stayed in the
walker's seen set and never reached the deleted path: its old vector rows
were never superseded and its hash state was never cleared, leaving stale
content searchable indefinitely against the incremental/zero-loss contract
in the function's own docstring.

Drop such a file from seen so the existing supersede path handles it (rows
stamped valid_to with the hidden_by marker, never hard-deleted) and its
file state is cleared. A file that was already blank is absent from the
prior state too, so it stays a no-op and does not churn on re-index.
ingest_folder opened its own CollectionStore and never closed it on any
path, so a server re-indexing on a timer leaked one sqlite connection (and
file handle) per run. The service wrappers all close theirs in a finally;
this path was the outlier.

Wrap the whole body in try/finally so the connection closes on success and
on the error path that records status=error.
The two HTTP surface blocks had bare fences (markdownlint MD040).
@jaylfc

jaylfc commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Thanks for the review. Both majors were genuine bugs, not false positives. Fixed in 5a9c0b6, 5621882, b63277e.

1. Emptied file left stale rows searchable (zero-loss contract violation)

Correct call. A previously-indexed file whose content became blank hit the continue before the supersede step, but rel was already in seen, so it counted as "still present": its old active rows were never superseded and its collection_files hash row was never cleared. The old content stayed in active recall indefinitely, which contradicts the incremental/zero-loss behaviour the docstring promises.

Fix: an emptied file is now dropped from seen, so it falls through to the existing deleted path. Its rows get superseded through _supersede_collection_rows (stamped valid_to with the hidden_by: collection-reindex:<ts> marker, never hard-deleted) and its file state is cleared, so a later refill re-indexes cleanly. A file that was already blank is absent from the prior state too, so it stays a no-op and does not churn on every re-index.

Regression tests: test_reindex_emptied_file_supersedes_rows indexes the fixture tree, asserts the content is searchable, truncates readme.md to whitespace, re-indexes, then asserts the old text is gone from active recall, the hash state is cleared, and the rows survive physically with the supersede marker. test_reindex_already_empty_file_is_a_no_op pins the no-churn half. The first test failed on the old code (chunks_superseded == 0, old text still returned); the second passed before and after, which is the point.

2. Store connection leaked in ingest_folder

Also correct. CollectionStore(resolved_dir) was never closed on any path, including the except that sets status=error. As you noted, the _collection_store service wrappers all close in a finally, so this was the one outlier, and a server doing periodic re-indexing accumulates a connection per run.

Fix: the whole body is wrapped in try/finally with store.close(), matching the service-layer pattern. Kept the archived-collection ValueError outside the inner status-setting try so an archived collection still raises without having its status overwritten to error.

Regression tests: test_ingest_folder_closes_the_store_on_success and test_ingest_folder_closes_the_store_on_error patch CollectionStore with a spy subclass that records close(), then assert every instance opened during the run was closed (the success test also asserts the underlying sqlite connection actually rejects a query afterwards). The error test induces the failure by patching collect_files to raise mid-index. Both failed on the old code.

Nitpick

docs/collections.md: added text as the language on the two HTTP surface fenced blocks (MD040).

Full suite green at 1190 passed (was 1186, plus the 4 new tests).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
tests/test_collections_ingest.py (2)

335-345: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Unused store variable (Ruff RUF059).

store, col = _make_collection(...) never uses store in this test. Elsewhere in the same file (line 389) the unused return is correctly bound to _store; this instance is inconsistent and will keep tripping the linter.

♻️ Proposed fix
-    store, col = _make_collection(data_dir, source_dir)
+    _store, col = _make_collection(data_dir, source_dir)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_collections_ingest.py` around lines 335 - 345, Update
test_reindex_already_empty_file_is_a_no_op so the unused first return value from
_make_collection is bound to the file’s established unused-variable placeholder,
matching the nearby test pattern; keep col unchanged for the ingest calls.

Source: Linters/SAST tools


368-381: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Narrow the blind except Exception (Ruff B017).

with pytest.raises(Exception): around s._conn.execute("SELECT 1") will also pass if execution fails for an unrelated reason (e.g. _conn missing/mocked wrongly), silently hiding a broken assertion. SQLite raises sqlite3.ProgrammingError for a closed connection — assert that specifically.

🐛 Proposed fix
+import sqlite3
+
     for s in made:
-        with pytest.raises(Exception):
+        with pytest.raises(sqlite3.ProgrammingError):
             s._conn.execute("SELECT 1")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_collections_ingest.py` around lines 368 - 381, Update the
closed-connection assertion in test_ingest_folder_closes_the_store_on_success to
expect sqlite3.ProgrammingError instead of the broad Exception type when
executing s._conn.execute("SELECT 1"). Ensure the test imports sqlite3 as needed
and preserves the existing validation that every tracked store is closed.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/test_collections_ingest.py`:
- Around line 335-345: Update test_reindex_already_empty_file_is_a_no_op so the
unused first return value from _make_collection is bound to the file’s
established unused-variable placeholder, matching the nearby test pattern; keep
col unchanged for the ingest calls.
- Around line 368-381: Update the closed-connection assertion in
test_ingest_folder_closes_the_store_on_success to expect
sqlite3.ProgrammingError instead of the broad Exception type when executing
s._conn.execute("SELECT 1"). Ensure the test imports sqlite3 as needed and
preserves the existing validation that every tracked store is closed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ba9681be-f5a4-4a88-864f-6144c4ce0e94

📥 Commits

Reviewing files that changed from the base of the PR and between 5edf175 and b63277e.

📒 Files selected for processing (3)
  • docs/collections.md
  • taosmd/collections.py
  • tests/test_collections_ingest.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/collections.md
  • taosmd/collections.py

jaylfc added 2 commits July 20, 2026 09:56
A previously-indexed file whose content becomes empty was routed through
the deleted path and counted in files_deleted, conflating two things a UI
needs to show apart: a file that vanished from disk versus a file that
still exists but has no content left.

Split the reporting with a distinct files_emptied counter. The underlying
behaviour is unchanged: both cases still supersede their old rows and
clear their file_states hash, so the zero-loss retirement holds. The key
is always present (0 when none) so the stats shape stays stable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant