Commit Graph
767 Commits
Author SHA1 Message Date
Chris CoutinhoandClaude Opus 4.8 e7c0c23486 fix(ingest): address review round 2 (stale gauge + hygiene)
- metrics: update_ingest_queue_depth now pre-zeroes every managed ingest queue
  before applying live counts, so a queue that drains to empty (and drops out of
  procrastinate's list_queues_async) reads 0 instead of sticking at its last
  non-zero value (ghost backlog in Grafana/alerts). Adds a regression test.
- procrastinate: comment that _is_transient_infra_error treats all qdrant errors
  as transient deliberately (bounded same-tier retry; over-broad is acceptable).
- escalation: note next_tier is the building block; production routing uses
  ProcessorRegistry.next_available_tier.
- tests: add evaluate_escalation fast+ocr-only low-confidence -> ocr case.

Deck #323.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:45:01 +02:00
Chris CoutinhoandClaude Opus 4.8 35f8204a16 fix(ingest): address review round 1 (reclaim queue + tests)
- Register the periodic stalled-job reclaim on a dedicated ingest-maintenance
  queue that every worker drains (any --tier), so reclaim still fires when the
  fast fleet is scaled to zero and only ocr workers run. procrastinate's
  periodic-defer dedup keeps it single-run across drainers.
- escalation: mark `unsupported`/`forced` reason labels as reserved (not raised).
- processor: note that options/progress_callback are intentionally not threaded
  through _parse_pdf_tier yet (symmetric with the inline path).
- tests: assert TieredEscalationStrategy backoff progression (4/8/16/…/300s);
  cover get_ingest_pending per-queue aggregation + the legacy job_counts
  fallback; add an external-path zero-page no-escalation case; use the canonical
  INGEST_QUEUE_FAST instead of the back-compat alias.

Deck #323.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:34:03 +02:00
Chris CoutinhoandClaude Opus 4.8 9676bb3106 feat(ingest): per-tier escalation via procrastinate queue-hop
Split external (procrastinate) document processing into per-tier queues so a
document is attempted at most once per tier and requeued to the next tier's
queue on a low-quality parse, using procrastinate's native retry.

- escalation.py: TIER_LADDER (fast->structured->ocr) + EscalateError signal
- registry: process_tier (one tier) + evaluate_escalation post-parse gate
  (reuses classify_from_text) + next_available_tier; shared _classify_result
  and _oversize_result with the inline pipeline
- processor: process_document(tier=...) runs one tier and raises EscalateError
  before embed (junk text never indexed); inline memory path unchanged
- queue/procrastinate: ingest-fast|structured|ocr queues; TieredEscalationStrategy
  (queue-hop on EscalateError, bounded same-tier transient retry); queue-aware
  task; producer defers to ingest-fast; per-queue counts + all-queue reclaim
- cli: worker --tier {fast,structured,ocr}
- billing: pages_ocr usage event + pipeline_tier metadata (paid OCR billed apart)
- observability: astrolabe_ingest_queue_depth{queue,status} gauge + per-queue
  counts in nc_get_vector_sync_status / management status endpoint
- config: INGEST_ESCALATION_ENABLED (default true), INGEST_TRANSIENT_MAX_ATTEMPTS

INGEST_ESCALATION_ENABLED=false and INGEST_QUEUE=memory preserve prior behaviour.

Deck #323.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:22:18 +02:00
Chris CoutinhoandClaude Opus 4.8 d75b7e1bd1 refactor(classifier): rename IMAGE_COVERAGE_SCANNED → IMAGE_HEAVY_THRESHOLD
Round-1 review nits. The constant now only gates the diagnostic `image_heavy`
flag (not routing), so the old name was misleading. Rename + reword its comment
to state the diagnostic-only intent. Also add a classify_pdf symmetry test
(`test_classify_pdf_image_heavy_clean_text_stays_fast`) pinning that a full-page
raster image with a clean text layer routes fast on the classify_pdf path too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:52:23 +02:00
Chris CoutinhoandClaude Opus 4.8 906a5805ef fix(classifier): make image coverage diagnostic-only, not an OCR routing trigger
The tier-0 classifier escalated any page with raster-image coverage >=0.80 to
the OCR tier regardless of its text layer. On OHR-Bench this drove ~45% of all
OCR escalations: clean born-digital pages dominated by a figure, and scanned
pages that already carry a usable OCR text layer -- re-OCR adds nothing for
either, but each one was routed to the paid tier-3 OCR.

Route on the text signals only (near-empty or junk-quality layer). Image
coverage is still computed and still raises the `image_heavy` diagnostic flag,
but no longer routes. True scans with no/garbage text continue to escalate via
the empty-text and quality signals, so genuine OCR needs are unaffected.

Trade-off: image-only content on an otherwise-clean page (handwriting, stamps,
text inside figures) is no longer force-routed to OCR. This was previously
intentional; the escalation cost outweighed the benefit for RAG indexing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:40:43 +02:00
Chris CoutinhoandClaude Opus 4.8 79d9d62e6a refactor(vector-sync): clear SonarCloud gate + round-2 nits
Quality-gate fixes (new-code conditions on PR #902):
- new_security_hotspots_reviewed: drop the fake "http://nextcloud" host in the
  manager tests to https:// (python:S5332 ×2).
- new_security_rating: generate the integration test's fake app password with
  secrets.token_urlsafe instead of a hardcoded literal (python:S2068).
- new_reliability_rating: restructure the user_manager sleep so an explicit
  await checkpoint lives inside the cancellation scope — await one waiter
  directly while watching shutdown via start_soon (python:S7490). Behaviour is
  unchanged: timeout, shutdown, or a provisioning ring all end the sleep.

Review nits:
- Move the shutdown test's fail_after(2) to wrap the whole task group so it
  actually bounds the task-group exit (was guarding a no-op sleep); drop the
  sleep(0) stub (python:S7491).
- Type _wake_on's wait_fn as Callable[[], Awaitable[object]].
- Note in _wire_vector_sync_state why provision_signal is set on the singleton
  only, not fanned out to app.state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:08:18 +02:00
Chris CoutinhoandClaude Opus 4.8 3f09453926 refactor(vector-sync): address round-1 review nits
- ProvisionSignal.wait() re-arms in a finally so a cancelled wait (shutdown
  racing the doorbell) leaves a fresh unset event, not a stale set-but-consumed
  one; preserves the no-await-before-swap lost-wakeup guarantee.
- Hoist user_manager_task's _wake_on helper out of the while loop (one object,
  not one per iteration).
- Test: assert ProvisionSignal via its public wait() contract instead of the
  private _event attribute.
- Add test_provision_app_password_wakes_user_manager covering the
  api/passwords.py wake path (previously only LFv2 web + MCP tool were tested).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:56:27 +02:00
Chris CoutinhoandClaude Opus 4.8 d8e3e9bc33 feat(vector-sync): scan provisioned users immediately
Background vector sync discovered newly provisioned users only on the
periodic user-manager poll (VECTOR_SYNC_USER_POLL_INTERVAL, default 60s),
delaying first indexing by up to a minute. Add a ProvisionSignal doorbell
that provisioning paths ring after storing a user's app password, waking
user_manager_task to re-poll and spawn the user's scanner at once. The
periodic poll remains the backstop (covers cross-replica provisioning).

- ProvisionSignal (stable reference, wait-and-re-arm) held on
  VectorSyncState; closes the lost-wakeup window (no await between observing
  the ring and re-arming; anyio.Event stickiness covers a mid-poll ring)
- user_manager_task races its poll timeout against the doorbell + shutdown
- notify_user_provisioned() rung from the three app-password provisioning
  sites: Login Flow v2 web, MCP provisioning tool, management/BasicAuth API

Note: the pre-existing scanner_wake_event was never .set() and only wakes
existing scanners; a brand-new user has none, so the manager is what must
be nudged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:48:56 +02:00
Chris CoutinhoandClaude Opus 4.8 570a651ac4 docs(auth): clarify unconditional offline_access advertising + test
Address round-1 claude-review nits on PR #894:
- Document why offline_access is advertised unconditionally (independent of
  settings.enable_offline_access): per RFC 8414, scopes_supported lists what
  the AS *can* support, with actual issuance still gated upstream by Nextcloud.
- Add a regression test proving the offline_access invariant holds on an empty
  FastMCP instance with no registered tools.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:35:28 +02:00
Chris CoutinhoandClaude Opus 4.8 c30a795a1f feat(auth): advertise offline_access in discovered OAuth scopes
discover_all_scopes() builds the scopes_supported lists exposed at
/.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server.
It previously emitted only the base OIDC scopes plus tool-derived
@require_scopes, so offline_access was never advertised and
discovery-driven MCP clients had no way to know they could request a
refresh token.

Add offline_access unconditionally. The AS proxy already forwards
client-requested scopes upstream to Nextcloud, which issues a refresh
token when the MCP server's OIDC client is permitted the scope. This
only changes what is advertised; it is not added to ALL_SUPPORTED_SCOPES
(the app-level permission set), since offline_access is an OIDC behavior
rather than a Nextcloud resource permission.

Add a regression test asserting offline_access is always present in
discover_all_scopes() output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:30:41 +02:00
Chris Coutinho 7d9e9b8ed3 Merge remote-tracking branch 'origin/master' into fix/309-embed-resilience
# Conflicts:
#	nextcloud_mcp_server/vector/processor.py
2026-06-11 10:46:57 +02:00
Chris CoutinhoandGitHub 0672e4a05c Merge pull request #892 from cbcoutinho/feat/309-ocr-timeout-pdf-size-guard
feat(document): configurable OCR timeout and fail-fast PDF size guard
2026-06-11 10:40:15 +02:00
Chris CoutinhoandGitHub 0c73fb7bff Merge pull request #891 from cbcoutinho/fix/309-dav-encoding-exceptiongroup
fix(vector): URL-encode DAV paths and unwrap TaskGroup exceptions
2026-06-11 10:39:20 +02:00
Chris CoutinhoandClaude Opus 4.8 81f7403b12 test(providers): Mistral batch retry + retry-log detail + comment (#893 r4)
Round-4 review on PR #893 (no blockers, minor items):
- Document why Mistral's _is_transient is SDK-level only (429/5xx): a bare
  connection drop the SDK surfaces as httpx/ConnectionError isn't an SDKError
  and isn't retried here by design — the pod-rollover target is the gateway
  (OpenAI-compatible) path, which does cover connection errors.
- Include the last error (%r) in the retry helper's "not resolved after N
  attempts" error log.
- Add test_mistral_embed_batch_retries_on_5xx (batch path parity with embed()).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:45:56 +02:00
Chris CoutinhoandClaude Opus 4.8 801bf108fa test(webdav): pin encode-once contract + nit cleanups (#891 r3)
Round-3 review on PR #891 (no blockers):
- Add test_encode_dav_path_encodes_exactly_once pinning the documented
  decoded-input precondition ("already%20encoded.pdf" -> "already%2520...").
- format_exception_group: proper singular/plural ("1 sub-exception" vs
  "N sub-exceptions") instead of "(s)".
- oauth_sync: use `if doc_task is not None:` to match processor_task's guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:21:36 +02:00
Chris CoutinhoandClaude Opus 4.8 ebd0b469f5 fix(document): catch httpx timeout from gateway OCR backend (#892 r3)
Round-3 review on PR #892 found a real bug: the gateway backend's httpx.Timeout
raises httpx.ReadTimeout (a httpx.TimeoutException, NOT a builtin TimeoutError),
so the `except TimeoutError` added in r2 only covered the Mistral
(anyio.fail_after) path — gateway timeouts still fell through to
reason="error". Catch both (TimeoutError, httpx.TimeoutException) so either
backend's timeout lands in the dedicated parse_failed_reason="timeout" bucket.
Add an end-to-end test driving a gateway httpx.ReadTimeout through the
processor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:19:53 +02:00
Chris CoutinhoandClaude Opus 4.8 c4b6d4a017 fix(vector): don't inflate qdrant-error metric on embed drops (#893 r3)
Round-3 review on PR #893:
- record_qdrant_operation("upsert","error") now fires only when the exhausted
  retry was actually a Qdrant failure (reason=="qdrant"); an embed/connection
  failure exhausts retries before Qdrant is called, so attributing it to
  mcp_qdrant_operations_total{error} inflated that signal. The cause is still
  captured by record_ingest_dropped.
- Add test_mistral_embed_retries_on_5xx: exercises the full Mistral retry path
  (5xx SDKError then success), not just the predicate.
- Add test_generate_does_not_retry_on_bad_request: generate() fast-fails on a
  permanent 4xx.
- Move astrolabe_vector_ingest_dropped_total's definition into the astrolabe_
  pipeline-metrics block (was in the mcp_ section).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:17:59 +02:00
Chris CoutinhoandClaude Opus 4.8 7b274cd8e2 test(webdav): direct _webdav_path test + double-encode precondition (#891 r2)
Round-2 review on PR #891 (non-blocking):
- Add a parametrised test_webdav_path_encoding covering empty path,
  leading-slash stripping, '#'/comma/space, and a non-ASCII name — the single
  source of truth for every caller-path builder's encoding, so write_file /
  delete_resource / create_directory / attachments are covered transitively.
- Document the decoded-input precondition on _webdav_path (encode-exactly-once;
  passing an already-encoded path would double-encode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:13:55 +02:00
Chris CoutinhoandClaude Opus 4.8 2f8875e736 fix(document): timeout reason bucket + Sonar https hotspot (#892 round 2)
Round-2 review on PR #892:
- OcrProcessor.process now catches TimeoutError separately and returns
  parse_failed_reason="timeout" with a populated message ("OCR timed out after
  Ns"), instead of conflating timeouts with API errors under "error" and logging
  an empty suffix. Lets dashboards tell a too-low timeout from a failing
  provider. Test added.
- Add validator-rejection tests for DOCUMENT_OCR_TIMEOUT_SECONDS=0 (gte=1) and
  DOCUMENT_MAX_PDF_SIZE_MB=-1 (gte=0), matching the existing validator-test
  pattern.
- Comment the _Settings test fixture's max_pdf_size_mb=0.0 default.

SonarCloud: quality gate was failing on new_security_hotspots_reviewed (S5332
"use https") from an http:// URL in the new gateway-timeout test — switched to
https:// (mirrors commit 98c9d58e).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:12:21 +02:00
Chris CoutinhoandClaude Opus 4.8 8f7a8432f5 test(vector): close generate()/drop-counter test gaps + https mock URLs (#893)
Round-2 review on PR #893:
- Add test_generate_retries_on_connection_error (generate() shares the transient
  retry; guards the decorator against accidental removal).
- Add test_process_document_records_drop_on_exhausted_retries: drives
  process_document to retry-exhaustion and asserts record_ingest_dropped is
  called once with the classified reason (processor-level coverage, not just the
  _drop_reason unit).
- Note in _drop_reason that a multi-failure group is labelled by its first leaf
  (best-effort, no "mixed" bucket).

SonarCloud: the quality gate was failing on new_security_hotspots_reviewed
(S5332 "use https") from http:// URLs in the test _req() helpers — switched to
https:// (mirrors commit 98c9d58e).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:09:59 +02:00
Chris CoutinhoandClaude Opus 4.8 6aa4b3f7b7 refactor(worker): trim observability helper docstring; clarify test fake
- Collapse _init_worker_observability's docstring to one line; the WHY moves
  to a concise inline comment (per review).
- Note that _fake_settings.ingest_queue is unused by the helper (test realism).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 05:45:47 +02:00
Chris CoutinhoandClaude Opus 4.8 6c99906ed4 fix(vector): nested-group drop classification + review/Sonar fixes (#893)
Round-1 review on PR #893:
- _drop_reason now descends through nested ExceptionGroups to the first leaf
  (was single-level), so a doubly-wrapped cause isn't mislabelled "other";
  added a nested-group test. Commented why both the httpx and openai isinstance
  branches exist (raw Nextcloud-API errors vs SDK-wrapped variants).
- Documented that generate() intentionally shares the broadened transient retry
  (RAG sampling path), with the worst-case latency note.
- Added a docstring note to process_document on how the provider-level retry
  (5x) layers over the outer loop (3x in-process / 1x procrastinate).
- Added test_embed_batch_retries_on_connection_error for the batch path.
- Renamed test_retry_reraises_non_rate_limit_immediately ->
  test_retry_reraises_when_predicate_returns_false (it tests the predicate, not
  a specific status).

SonarCloud:
- S5708 (BLOCKER) on the helper's dynamic `except exception_type`: the type is
  constrained to BaseException/tuple by the signature; suppressed with a
  justified NOSONAR.
- S7503 (async without await) in the embed-retry test: use AsyncMock side_effect
  instead of a hand-rolled async function.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 05:39:23 +02:00
Chris CoutinhoandClaude Opus 4.8 eab090f351 fix(worker): clear Sonar S5332 hotspot + address review nits
- tests: use https in the OTLP endpoint fixture to clear the S5332
  "http protocol is insecure" security hotspot (quality gate:
  new_security_hotspots_reviewed).
- cli: add the "tracing disabled" else branch in
  _init_worker_observability so the worker logs parity with app.py when no
  OTLP endpoint is set.
- cli: trim the verbose inline comment in worker() (the WHY lives in the
  helper docstring), per review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 05:37:07 +02:00
Chris CoutinhoandClaude Opus 4.8 64ea5c8631 fix(document): apply OCR timeout to Mistral backend + review/Sonar fixes (#892)
Round-1 review on PR #892:
- Wire DOCUMENT_OCR_TIMEOUT_SECONDS into _MistralOcrBackend too (was
  gateway-only): wrap process_async in anyio.fail_after so the SDK-managed
  client honours the setting; on expiry it fails fast as a clean parse error.
  Test added.
- Tighten the misleading "honoured without a restart" comment — per-call
  get_settings() is for test monkeypatching; a live change still needs a
  restart since the backend is cached for the pod lifetime.
- Comment the size guard's two intentional gaps: an explicit processor_name
  override bypasses it, and the early return skips the parse-duration histogram.

SonarCloud (new-code smells in the added tests):
- S1244 float-equality asserts → pytest.approx (test_config.py, test_ocr_processor.py).
- S1186/S7503: rewrite the gateway-timeout test with mocker AsyncMock/MagicMock
  instead of a hand-rolled fake client (no empty method, no async-without-await).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 05:36:47 +02:00
Chris CoutinhoandClaude Opus 4.8 a188e9fced fix(vector): guard unbound doc_task + address review nits (#891)
Round-1 review on PR #891:
- Guard processor_task's broad except handler against an unbound doc_task
  (mirrors multi_user_processor_task): initialise doc_task=None before the loop
  and branch the error log. Fixes a latent NameError if receive() raises a
  non-TimeoutError/EndOfStream before the first document binds. Regression test
  added.
- Drop the unnecessary `from __future__ import annotations` in vector/_errors.py
  and express format_exception_group's non-group fast path as an explicit
  isinstance check.
- Add a copy_resource Destination-header encoding test (analogue to MOVE);
  strengthen the ExceptionGroup test to assert the full leaf repr survives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 05:31:56 +02:00
Chris CoutinhoandClaude Opus 4.8 04bda07de2 feat(worker): structured logs + metrics + traces for ingest worker
The external split-worker ingest pods (MCP_ROLE=worker / procrastinate) had
no observability: the worker CLI entrypoint never started a Prometheus
metrics server and never configured structured logging, so the pods that do
the real parse/embed/upsert work were invisible to Prometheus and emitted
plain-text logs the platform pipeline couldn't parse.

The always-on API pod bootstraps observability in its lifespan (app.py), but
the worker has its own entrypoint and never went through that path (or
uvicorn's JSON log_config). Add `_init_worker_observability()` mirroring the
API pod: setup_logging (JSON), setup_metrics on METRICS_PORT when
METRICS_ENABLED, and setup_tracing when an OTLP endpoint is configured.
Runs after the INGEST_QUEUE=postgres check so a misconfigured worker fails
fast without binding a metrics port.

This also unblocks the document-pipeline observability shipped in #831
(Deck #175): the astrolabe_* parse/embed/chunk metrics and the
document_processor.parse span are recorded in the shared registry/processor
code the worker executes — they were simply never exposed in external mode
because the worker served no /metrics and set up no tracer.

Deck #310, unblocks #175.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 05:23:33 +02:00
Chris CoutinhoandClaude Opus 4.8 258ee96f4c fix(vector): retry transient embed errors so a pod rollover drops 0 docs
From card 309 (OHR-Bench smoke-test triage): during a backend-pod rollover the
embedding endpoint was briefly unreachable, and openai.APIConnectionError /
ConnectError propagated unretried (the provider only retried 429). Documents
exhausted the 3 in-process retries and were dropped for that scan cycle.

Broaden the provider-level retry to the transient set -- APIConnectionError,
APITimeoutError, 429, and 5xx -- on the existing exponential backoff (2s->60s,
5 attempts), so a few seconds of retry rides through the rollover. Permanent
4xx (auth, bad request) still re-raise immediately. Generalize the shared
_retry helper (retry_on_rate_limit -> retry_on_transient, predicate renamed to
should_retry, accurate log label) with a back-compat alias; Mistral gets 429+5xx
for parity. The production gateway path inherits this via GatewayProvider, which
delegates to the decorated OpenAIProvider methods.

Add astrolabe_vector_ingest_dropped_total{reason}, incremented when a document
exhausts retries, classified (connection|timeout|rate_limit|server|qdrant|other)
by _drop_reason so the embed-drop rate is alertable per cause. Dropped docs are
NOT marked failed, so the next full scan re-picks them (re-queue via scan loop).

Refs: Deck board 12 card 309 (AC #1 no permanently-dropped docs; embed-drop
metric for AC #5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 05:22:31 +02:00
Chris CoutinhoandClaude Opus 4.8 523e4cb7b5 feat(document): configurable OCR timeout and fail-fast PDF size guard
Two ingest-robustness fixes from card 309 (OHR-Bench smoke-test triage).

The OCR backend timeout was a hardcoded 180s module constant, so a tenant
whose gateway has its own shorter ceiling couldn't tune it. Promote it to
DOCUMENT_OCR_TIMEOUT_SECONDS (default 180), resolved per call via get_settings
so an override applies without a restart.

Large, awkward PDFs (e.g. a 42 MB scanned DUDE) were handed straight to the
fast/OCR tiers, where they burned the full OCR timeout for zero recovered
text. Add a pre-parse size guard in the tiered PDF pipeline: a PDF over
DOCUMENT_MAX_PDF_SIZE_MB (default 50, 0 disables) fails fast with
parse_failed_reason="oversize" before any tier runs, so the existing
permanent-failure path marks the placeholder failed and records
astrolabe_document_parse_failed_total{reason="oversize"} instead of retrying.

Both knobs go through Settings + dynaconf validators (env-var keys verified by
regression tests) and are documented under Background Indexing Configuration.

Refs: Deck board 12 card 309 (AC #3 OCR timeout + size guard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 05:09:58 +02:00
Chris CoutinhoandClaude Opus 4.8 0388735593 fix(vector): URL-encode DAV paths and unwrap TaskGroup exceptions
Two ingest-robustness fixes from card 309 (OHR-Bench smoke-test triage).

WebDAV paths flowed through the client already URL-decoded (unquote on the
PROPFIND/REPORT <d:href>, or raw MCP-tool input), so a '#' reached httpx as a
URL fragment and silently truncated the request -> spurious 404 on otherwise
valid files (e.g. law filenames with '#', commas, double/trailing spaces).
Route every caller-path builder through a new _webdav_path helper that
percent-encodes the path once (preserving separators); the MOVE/COPY
Destination header is encoded too.

Vector-sync runs inside anyio task groups, so a child-task failure surfaced as
a BaseExceptionGroup whose str() is the useless "unhandled errors in a
TaskGroup (N sub-exception)" -- hiding the real ConnectError operators need.
Add format_exception_group to flatten the group to its leaf exceptions and use
it at the broad catch/log sites in processor.py and oauth_sync.py.

Refs: Deck board 12 card 309 (AC #4 filename handling, AC #2 observability).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 04:59:53 +02:00
Chris CoutinhoandClaude Opus 4.8 d887181307 docs(deck): document assignedUsers preservation on cross-board move
Round-6 review: the docstrings listed preserved fields but omitted
assignedUsers. Verified empirically (Deck 1.15.9) that the update route's
board-change handling only remaps labels and leaves user assignments
untouched, so assignees carry over. Documented in both the client and MCP
tool docstrings, with the caveat that an assignee lacking access to the
target board stays assigned but cannot act on the card. Added
test_move_card_to_board_preserves_assigned_users to lock it in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 00:12:35 +02:00
Chris CoutinhoandClaude Opus 4.8 5ae9cc2a98 fix(deck): make done-restore best-effort on move; cover combined states
Round-4 review polish on PR #885:

- The post-move done re-mark is now best-effort: the move PUT has already
  committed by then, so if the /done call (or its re-fetch) fails, log a
  warning with the card's new location and return the moved card instead of
  raising as if the whole move failed. Documented in the docstring.
- Note that duedate is sent explicitly as None (vs update_card omitting it) —
  equivalent for this route.
- Add unit coverage for the swallowed done-restore failure, and an integration
  test for a card that is both done and archived (exercises the done-restore
  re-fetch on an archived card).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 00:00:21 +02:00
Chris CoutinhoandClaude Opus 4.8 69b32f345c feat(deck): surface remapped labels in move-card response
Round-3 review polish on PR #885:

- deck_move_card_to_board now captures the moved DeckCard and returns its
  post-move label titles in CardOperationResponse.labels, so LLM clients can
  confirm the cross-board label remap (the tool's headline behaviour) without
  a follow-up deck_get_card. The field is optional and defaults to None for
  the other card operations that share this response model.
- Tighten test_move_card_to_board_restores_done_state to assert the returned
  card reflects the restored done state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 23:53:01 +02:00
Chris CoutinhoandClaude Opus 4.8 7a39767482 docs(deck): note owner reassignment on move; add archived-preservation test
Round-2 review polish on PR #885:

- Document in the deck_move_card_to_board tool that the move reassigns the
  card owner to the calling user and resets the done timestamp (both are
  limitations of Deck's move route), so an LLM reading only the tool
  description isn't misled about preserved fields.
- Fix the done integration-test docstring to say "done state (not timestamp)".
- Add test_move_card_to_board_preserves_archived_status to lock in the
  documented archived-preservation behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 23:47:15 +02:00
Chris CoutinhoandClaude Opus 4.8 798a00d89d fix(deck): preserve done/archived and validate target board on move
Addresses the round-1 review on PR #885:

- Preserve `done` across a cross-board move. The internal card-update route
  (the only one that works cross-board — the board/stack-scoped route 404s for
  a card not already on that board) does not accept a done value, so a "done"
  card is re-marked done after the move. Deck stamps the current time there, so
  the original timestamp isn't preserved — documented as a route limitation.
  (`archived` is already preserved: CardService only mutates it when sent.)
- Validate that target_stack_id is on target_board_id before moving, so the
  parameter is load-bearing and a mismatch fails loudly instead of misreporting.
- Skip the same-board guard's get_stacks round-trip on a same-stack reorder.
- Add unit coverage (done-restore call, destination validation, same-stack
  skip) and integration coverage (done preservation, target-board mismatch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 23:41:07 +02:00
Chris CoutinhoandClaude Opus 4.8 437eaa0872 feat(deck): add deck_move_card_to_board tool for cross-board moves
deck_reorder_card only relocated a card between stacks on the same board.
Moving a card to another board now has a dedicated tool that goes through
Deck's card-update route (CardService::update), which remaps the card's
board-scoped labels to the destination board by title instead of leaving
orphaned labels behind. Card identity (id, comments, attachments) is
preserved.

reorder_card is now restricted to same-board moves: it rejects a
target_stack_id on another board (which Deck's reorder route would accept
but with orphaned labels), steering clients to deck_move_card_to_board.

Verified empirically against Deck 1.15.9: the reorder route leaves a moved
card carrying its source board's label (boardId mismatch); the update route
remaps it to the destination board's same-titled label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 23:25:15 +02:00
Chris CoutinhoandGitHub a336b8eb0d Merge pull request #883 from cbcoutinho/worktree-tender-stargazing-sundae
test: Pact consumer contract for astrolabe credentials status (ADR-029)
2026-06-10 22:26:39 +02:00
Chris CoutinhoandClaude Opus 4.8 8a9b350c63 fix(app): port-aware MCP URL fallback + clear readiness cache per lifespan (round 4)
- _default_mcp_server_url() replaces the hardcoded localhost:8000 fallback,
  deriving the port from settings.port so a custom PORT is honoured and all
  fallback sites share one source of truth (removes the footgun where
  settings.port looked wired but the OAuth-audience fallback ignored it).
- Clear _readiness_cache.statuses at loop start so dependency entries from a
  prior lifespan run in the same process (integration matrix) don't linger as
  stale, confusing checks output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:09:01 +02:00
Chris CoutinhoandClaude Opus 4.8 d14a167b30 refactor(app): cancel only the readiness loop at shutdown (review round 3)
Replace the blanket tg.cancel_scope.cancel() with a per-task CancelScope: the
readiness loop reports its scope via task_status, and the lifespan cancels just
that scope at shutdown. The task group's exit then waits for the scanner/
processor tasks to drain on their shutdown_event instead of force-cancelling
them mid-work, restoring the graceful drain the pre-refactor code had.

Also use docstrings instead of bare `return` in the no-op mode closures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:00:38 +02:00
Chris CoutinhoandClaude Opus 4.8 fc62a30384 fix(app): cancel readiness loop on lifespan shutdown (review round 2)
_readiness_refresh_loop is started with tg.start_soon and loops forever with no
shutdown_event check. anyio waits for start_soon tasks on normal task-group exit
rather than cancelling them, so graceful shutdown hung until uvicorn's timeout.
Cancel the task group's scope after teardown() to stop the loop and any
stragglers, mirroring _maybe_login_flow_cleanup.

Also document the cache ttl_seconds startup-override and the inclusive is_stale
boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:52:48 +02:00
Chris CoutinhoandClaude Opus 4.8 cc2ce6e853 fix(config): correct OIDC token-type/scopes env keys; address review round 1
- _DEFAULTS keys for NEXTCLOUD_OIDC_TOKEN_TYPE / NEXTCLOUD_OIDC_SCOPES were
  registered as oidc_* (uppercasing to OIDC_*), so dynaconf
  (ignore_unknown_envvars) never read the NEXTCLOUD_-prefixed env vars and the
  fields stayed at their defaults. Prefix the keys to match _field_map; add a
  regression test.
- Add gte=1 validator for HEALTH_READY_REFRESH_INTERVAL and a 1..65535 range
  validator for PORT.
- Tie ReadinessCache.ttl_seconds to 2x the configured refresh interval so
  is_stale() stays meaningful when the interval is tuned.
- Raise the refresh-loop exception log from DEBUG to WARNING.
- Make health_ready a sync handler (no awaits); dedupe the localhost fallback
  into _DEFAULT_MCP_SERVER_URL; use pytest.approx for the float default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:45:02 +02:00
Chris CoutinhoandClaude Opus 4.8 69c40a0479 fix: convert astrolabe int provisioned_at to ISO before ProvisioningStatus
Round-4 review (real bug): get_background_sync_status now returns provisioned_at
as Unix seconds (the wire/pact value), but ProvisioningStatus.provisioned_at is
str | None (ISO). Constructing it for a provisioned user raised a Pydantic
ValidationError — a path that was unreachable before the has_access fix.

Convert int -> ISO at the oauth_tools boundary (mirroring the existing
refresh_token branch), keeping the model schema and the int-asserting contract
pact/unit tests intact. Add a regression test that drives the full
_get_provisioning_status round-trip with an integer timestamp.

Also surface dropped provider-state params in the verifier's _dispatch_state
no-op branch (round-4 nit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:36:27 +02:00
Chris CoutinhoandClaude Opus 4.8 6ef7786cec fix(health): non-gating readiness probe; shared-task-group lifespan; settings migration
Fixes MCP reconnect timeouts on tenant servers (Deck #302). Three changes:

- /health/ready now gates only on local config. Nextcloud/Qdrant health is
  refreshed by a background loop, cached, and reported but NON-gating, so a
  single-replica tenant Pod is no longer pulled from its Service on a transient
  dependency blip (which dropped every MCP streamable-HTTP session and caused
  reconnect timeouts). The probe path performs no external I/O.
- Refactor starlette_lifespan: collapse the four near-identical per-mode
  task-group + session + yield + teardown skeletons into one shared task group
  that also runs the readiness refresh loop; each mode contributes a
  (start, teardown) pair. eviction_task_group is now always present.
- Migrate app.py off os.getenv: all config is read through dynaconf Settings
  (adds health_ready_refresh_interval, oidc_token_type, oidc_scopes, port).
  Inline/dynamic defaults preserved at each call site.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:28:19 +02:00
Chris CoutinhoandClaude Opus 4.8 18baa501c9 test: address round-2 claude-review on #883
- pact.yml: guard `can-i-deploy` job on `env.PACT_BROKER != ''` so a secret
  rotation/fork can't break every master merge (the CLI errors on empty URL)
- pact.yml: pin install.sh to the v2.6.1 commit SHA (immune to tag force-push)
- astrolabe_client.py: `_token_cache` Optional[dict] -> `dict | None` and drop
  the now-unused `Optional` import (CLAUDE.md union syntax)
- add tests/unit/test_astrolabe_client.py: mocked unit coverage for
  get_background_sync_status field mapping (200 provisioned / 200 not-provisioned
  / 404) — the layer that would have caught the original silent app_password bug
- consumer pact test: note the 404 branch is internal defensive handling (covered
  by the unit test), not a contract obligation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:53:54 +02:00
Chris CoutinhoandClaude Opus 4.8 d33832aba9 test: add Pact consumer contract for astrolabe credentials status (ADR-029)
Introduce consumer-driven contract testing between nextcloud-mcp-server and the
astrolabe Nextcloud app, published to the homelab Pact Broker and verified in CI.

- pact-python dev dep + `contract` pytest marker
- tests/contract/test_astrolabe_credentials_consumer.py: consumer pact for the
  background-sync *status* call (provisioned -> has_background_access:true,
  sync_type:"app_password", integer provisioned_at; unprovisioned -> false/null)
- tests/contract/test_mcp_provider_verification.py: env-gated Verifier harness
  for this server's /api/v1/* provider role (provider-state handlers stubbed
  pending astrolabe's published pacts)
- .github/workflows/pact.yml: join tailnet -> publish pacts -> provider verify
  -> can-i-deploy; broker steps skip when PACT_BROKER is unset (forks)
- docs/ADR-029-pact-contract-testing.md

Fix astrolabe_client.get_background_sync_status: it previously read a
non-existent `app_password` field (always reporting no-access). Rewrite it to
read the real status contract (has_background_access / sync_type /
provisioned_at) and drop the unsatisfiable get_user_app_password.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:22:07 +02:00
Chris CoutinhoandClaude Opus 4.8 a3178cf1fa refactor(metering): harden page_count guard per review
Round-1 review follow-ups (PR #879):
- Gate pages_embedded on `page_count and page_count > 0` so a malformed
  negative count meters as "no pages" rather than emitting a negative
  billing row (matches the documented call-site intent).
- Exclude bool at the call-site narrowing (`isinstance(int) and not
  isinstance(bool)`) — bool is an int subclass, so a stray page_count=True
  would otherwise record pages=1.
- Document chunk_count's role (empty-batch no-op guard) and the
  intentional tokens-before-pages ordering in the docstring/comments.
- Add test_negative_pages_skips_pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:03:46 +02:00
Chris CoutinhoandClaude Opus 4.8 9c89a58a07 feat(metering): pages_embedded = real parsed page count
`pages_embedded` carried an interim chunk count (`len(chunk_texts)`,
TODO #282). Reframe it as a charge for *parsing* (PDF page extraction /
OCR) rather than a normalized content size:

- Parsed files (PDFs) record `pages_embedded` = real `page_count` from
  the document processor metadata.
- Text content (notes, deck cards, news items) is never parsed, carries
  no `page_count`, and records no `pages_embedded` row — only
  `tokens_embedded`. There is deliberately no chars/tokens-per-page
  constant; pages map 1:1 to parsed document pages.

`record_indexing_usage` now takes `page_count` and records the two
dimensions independently, gating `pages_embedded` on a truthy page count
(not the doc_type) so a future non-PDF parsed type stays correct. Stays
flag-gated + best-effort. Tests cover parsed-file, text-only, and
zero-page cases.

Deck #282 (board 8). Billing-model ADR corrected in
astrolabe-cloud-website docs/control-plane/usage-metering.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:58:31 +02:00
Chris CoutinhoandGitHub 412d77f437 Merge pull request #878 from cbcoutinho/fix/windows-resource-import-877
fix(documents): guard Unix-only resource import for Windows (#877)
2026-06-08 16:22:42 +02:00
Chris CoutinhoandGitHub 8782c60302 Merge pull request #875 from cbcoutinho/feat/meter-embedding-tokens
feat(usage): meter embedding tokens (tokens_embedded/pages_embedded) on both paths + Prometheus export
2026-06-08 15:18:36 +02:00
Chris CoutinhoandClaude Opus 4.8 62274069de refactor(documents): fully decouple document stack from server startup; Windows-safe tests
Addresses round-1 review on #878:

- Move the eager `document_processors` imports out of the API startup graph:
  `app.py` (get_registry now imported inside initialize_document_processors,
  after the disabled early-return) and `vector/processor.py` (get_registry now
  imported at its single use site). Importing `app` + `cli` no longer loads
  `document_processors` / `_isolation` at all -- the #877 stack is fully out of
  startup (pymupdf still loads via search/pdf_highlighter, a Windows-compatible
  and separately-tracked concern).
- Make `tests/unit/test_pdf_parse_isolation.py` importable on Windows: guard the
  top-level `import resource` with try/except and skip the three rlimit
  computation tests via a `requires_resource` marker when the module is absent.
  The Windows no-op / import-guard tests don't use the real module and still run.
- Fix the `# pragma: no cover` comment on the win32 branch to be accurate.
- Add `enable-cache: true` to the package-smoke setup-uv step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:11:18 +02:00
Chris CoutinhoandClaude Opus 4.8 fc8a4e4dfa fix(documents): guard Unix-only resource import for Windows (#877)
`document_processors/_isolation.py` did an unconditional module-level
`import resource`, a POSIX-only stdlib module absent on Windows. It was
pulled into the API startup path via
`server/webdav.py -> utils/document_parser -> document_processors`, so
the MCP server failed to start on Windows since 0.101.2 with
`ModuleNotFoundError: No module named 'resource'`.

- Guard the import behind `sys.platform`; bind `resource = None` on
  win32. `_apply_mem_limit()` degrades to a logged no-op when the module
  is unavailable (the RLIMIT_AS cap is a Linux-pod safety measure, not a
  correctness requirement).
- Make the document-parser import in `server/webdav.py` lazy so server
  startup never loads the ingest document stack
  (document_processors -> pymupdf -> _isolation) at all -- it is only
  needed when a file is actually read and parsed. This both fixes #877
  and decouples the API layer from ingest-only deps.
- Add unit regressions for the no-op path and the win32 import guard.
- Add a cross-platform `package-smoke` CI job (ubuntu + windows) that
  installs the package isolated and runs the CLI, exercising the
  cli -> server -> webdav import chain that crashed in #877.

Fixes #877

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:59:03 +02:00