secondbrain-py 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
brain/vault/watch.py
ADDED
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
"""Long-running watcher for ``brain vault sync --watch``.
|
|
2
|
+
|
|
3
|
+
A watcher daemon that reconciles vault edits into the DB without the user
|
|
4
|
+
having to run ``brain vault sync`` after every save. The lifecycle is:
|
|
5
|
+
|
|
6
|
+
1. Run a one-shot ``sync_vault`` on startup so any edits made while the
|
|
7
|
+
watcher was off get caught up.
|
|
8
|
+
2. Start a watchdog ``Observer`` that emits filesystem events from the OS
|
|
9
|
+
(FSEvents on macOS, inotify on Linux). The Observer runs on its own
|
|
10
|
+
thread and pushes raw events through ``_handle_event``.
|
|
11
|
+
3. ``_handle_event`` filters out non-``.md`` paths, hidden directories,
|
|
12
|
+
templates, and attachments, then debounces per absolute path with a
|
|
13
|
+
``threading.Timer``. When a path's debounce fires it enqueues a
|
|
14
|
+
``_Job`` onto the worker queue.
|
|
15
|
+
4. A single worker thread consumes the queue serially. It owns the only
|
|
16
|
+
DB connection used by the watcher (we never share a psycopg connection
|
|
17
|
+
across threads). Each job runs ``sync_one_file`` (created/modified) or
|
|
18
|
+
removes the row + relinks (deleted) for the affected path. Errors from
|
|
19
|
+
one job never kill the worker — they're logged and the loop continues.
|
|
20
|
+
5. SIGINT / SIGTERM flip a ``threading.Event``; the main thread stops the
|
|
21
|
+
Observer, waits for any in-flight debounce timers to fire (so pending
|
|
22
|
+
edits drain instead of getting dropped), then joins the worker.
|
|
23
|
+
|
|
24
|
+
Concurrency invariants worth restating:
|
|
25
|
+
|
|
26
|
+
- Exactly one psycopg connection lives in the worker thread. The Observer
|
|
27
|
+
thread never touches the DB. The main thread never touches the DB
|
|
28
|
+
after the startup sync returns. (Sharing connections across threads
|
|
29
|
+
triggers psycopg ``InterfaceError: another command is already in
|
|
30
|
+
progress`` under load.)
|
|
31
|
+
- ``_pending_timers`` and ``_jobs`` are only touched while holding
|
|
32
|
+
``_state_lock`` — a short, fine-grained mutex that doesn't block the
|
|
33
|
+
worker (the worker holds the queue's lock instead).
|
|
34
|
+
- The debounce buffer is capped (``_MAX_PENDING``) so a runaway editor
|
|
35
|
+
spamming events can't OOM the process; on overflow we trigger an
|
|
36
|
+
immediate full sync and clear the buffer.
|
|
37
|
+
|
|
38
|
+
Fence-only writes (Phase D, Task D.5):
|
|
39
|
+
|
|
40
|
+
The metadata-aware linker rewrites a fenced ``BRAIN_DERIVED_*`` section
|
|
41
|
+
inside every affected ``_ingested/`` body whenever derived edges are
|
|
42
|
+
rebuilt (`docs/specs/2026-04-30-derived-edges-in-bodies-design.md`).
|
|
43
|
+
Decision Q4=(b) means the renderer rewrites the fence even when its
|
|
44
|
+
content is byte-identical, which guarantees a filesystem mtime bump and
|
|
45
|
+
therefore another watch event. Without dedup, that event would re-enter
|
|
46
|
+
``sync_one_file`` → ``rebuild_derived_for`` → ``rewrite_derived_fences``
|
|
47
|
+
and we'd loop forever.
|
|
48
|
+
|
|
49
|
+
The dedup lives in the worker: every successful sync caches
|
|
50
|
+
``strip_fence(file_contents)`` keyed by absolute path. When a later
|
|
51
|
+
event fires for that path, the worker reads the file, strips the fence,
|
|
52
|
+
and compares to the cache. Fence-only changes (or no change at all) skip
|
|
53
|
+
``sync_one_file``. Cold start (first sight of a path), cache miss after a
|
|
54
|
+
delete, or any genuine body change all fall through to the normal sync
|
|
55
|
+
path, so we never silently drop a real edit.
|
|
56
|
+
"""
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import contextlib
|
|
60
|
+
import logging
|
|
61
|
+
import os
|
|
62
|
+
import queue
|
|
63
|
+
import signal
|
|
64
|
+
import threading
|
|
65
|
+
from collections.abc import Callable
|
|
66
|
+
from dataclasses import dataclass, field
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
69
|
+
|
|
70
|
+
import psycopg
|
|
71
|
+
from watchdog.events import (
|
|
72
|
+
FileSystemEvent,
|
|
73
|
+
FileSystemEventHandler,
|
|
74
|
+
)
|
|
75
|
+
from watchdog.observers.api import BaseObserver
|
|
76
|
+
from watchdog.observers.polling import PollingObserver as Observer
|
|
77
|
+
|
|
78
|
+
# brain (2026-05-08): use PollingObserver instead of watchdog's default
|
|
79
|
+
# Observer (which on macOS dispatches via FSEventsObserver). On Python 3.13
|
|
80
|
+
# the FSEvents backend silently stops delivering events: the Observer
|
|
81
|
+
# starts cleanly and stays alive, but `on_any_event` is never called.
|
|
82
|
+
# No error, no log. Both this watcher and `brain.wiki.build_watcher` were
|
|
83
|
+
# affected — every edit to a vault `.md` was invisible until manual sync.
|
|
84
|
+
# PollingObserver scans the tree on a timer (default 1s) and synthesizes
|
|
85
|
+
# events from mtime/size diffs. CPU cost is a stat() per file per second
|
|
86
|
+
# (negligible for ~1100 docs); latency is the polling interval.
|
|
87
|
+
from ..ingest import Embedder
|
|
88
|
+
from .derived_links.fence import strip_fence
|
|
89
|
+
from .sync import SyncReport, sync_one_file, sync_vault
|
|
90
|
+
|
|
91
|
+
if TYPE_CHECKING:
|
|
92
|
+
from ..graph_rag.sync import GraphSyncer
|
|
93
|
+
|
|
94
|
+
logger = logging.getLogger(__name__)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Per-file actions the worker can run. A within-vault markdown rename is a
|
|
98
|
+
# single ``move`` job (carrying both src + dst) so the worker can UPDATE the
|
|
99
|
+
# row's ``vault_path`` in place — preserving the document id and, with it,
|
|
100
|
+
# every incoming backlink (``links.dst_document_id``). Moves whose
|
|
101
|
+
# destination leaves the vault (or stops being a ``.md`` file) still
|
|
102
|
+
# decompose into ``delete(src)`` + ``upsert(dst)`` in ``_classify_event``.
|
|
103
|
+
_Action = Literal["upsert", "delete", "move"]
|
|
104
|
+
|
|
105
|
+
# Hard cap on the debounce buffer. If the user (or an editor's autosave
|
|
106
|
+
# loop) outpaces this, we fall back to a full vault sync to recover —
|
|
107
|
+
# conceptually an admission-control valve, not a normal code path.
|
|
108
|
+
_MAX_PENDING = 1000
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class WatchConfig:
|
|
113
|
+
"""User-facing knobs for the watcher.
|
|
114
|
+
|
|
115
|
+
``debounce_ms`` is the per-path quiet period: rapid bursts (e.g. an
|
|
116
|
+
editor's "save → reformat → save again" cycle) collapse into one sync
|
|
117
|
+
after the path has been quiet for this long. The production default
|
|
118
|
+
(500 ms) is comfortable on macOS FSEvents which itself coalesces;
|
|
119
|
+
tests use a much smaller value (10 ms) to stay snappy without
|
|
120
|
+
sleeping.
|
|
121
|
+
|
|
122
|
+
``prune`` is plumbed through to the startup ``sync_vault`` call only —
|
|
123
|
+
we don't auto-prune in response to single-file delete events because
|
|
124
|
+
we don't want a race between "file moved to a temp dir while editor
|
|
125
|
+
saves" and "row gone from DB" to cause data loss. A delete event
|
|
126
|
+
triggers a row-removal for that single document, which is precise.
|
|
127
|
+
|
|
128
|
+
``on_event`` is a test-only hook called once per debounced job *just
|
|
129
|
+
before* it's enqueued. Production passes ``None``; tests pass a
|
|
130
|
+
closure to assert which paths actually made it through filtering.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
vault_path: Path
|
|
134
|
+
debounce_ms: int = 500
|
|
135
|
+
prune: bool = False
|
|
136
|
+
on_event: Callable[[_Action, Path], None] | None = None
|
|
137
|
+
# Plumbed from ``brain vault sync --no-link-rewrite``: when False, neither
|
|
138
|
+
# the startup full sync nor the per-file sync will rewrite vault-tier
|
|
139
|
+
# ``[[…]]`` markers to vault-root-relative path form. The rewrite is on
|
|
140
|
+
# by default — ``False`` is opt-out for users who want to preserve the
|
|
141
|
+
# exact authored shape of their wiki-links.
|
|
142
|
+
link_rewrite: bool = True
|
|
143
|
+
# Plumbed from ``Config.owner_participants`` (env: BRAIN_OWNER_PARTICIPANTS):
|
|
144
|
+
# identifiers (emails / display names, lowercased + trimmed at config-load
|
|
145
|
+
# time) stripped from each ``DocSnapshot.participant_keys`` before R2/R3
|
|
146
|
+
# rule evaluation in the linker pass. Empty frozenset (default) disables
|
|
147
|
+
# the filter — used by every existing test.
|
|
148
|
+
owner_participants: frozenset[str] = frozenset()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass
|
|
152
|
+
class _Job:
|
|
153
|
+
"""One unit of work for the worker thread.
|
|
154
|
+
|
|
155
|
+
``dest_path`` is only set for ``move`` jobs — it carries the rename
|
|
156
|
+
destination so the worker can UPDATE ``documents.vault_path`` from
|
|
157
|
+
``abs_path`` (the source) to ``dest_path`` in place, preserving the
|
|
158
|
+
document id and every incoming backlink. It stays ``None`` for every
|
|
159
|
+
other action.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
action: _Action
|
|
163
|
+
abs_path: Path
|
|
164
|
+
dest_path: Path | None = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass
|
|
168
|
+
class _WatcherState:
|
|
169
|
+
"""Mutable state shared across threads — guarded by ``lock``."""
|
|
170
|
+
|
|
171
|
+
pending_timers: dict[Path, threading.Timer] = field(default_factory=dict)
|
|
172
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
173
|
+
stop_event: threading.Event = field(default_factory=threading.Event)
|
|
174
|
+
jobs: queue.Queue[_Job | None] = field(default_factory=queue.Queue)
|
|
175
|
+
# ``processed`` and ``errors`` accumulate over the watcher's lifetime
|
|
176
|
+
# so the final shutdown report can summarize "watched 14 files; 1
|
|
177
|
+
# error". Only the worker thread touches them, so no lock is needed.
|
|
178
|
+
processed: int = 0
|
|
179
|
+
errors: int = 0
|
|
180
|
+
skipped_fence_only: int = 0
|
|
181
|
+
# Cache of the fence-stripped body last observed AFTER a successful
|
|
182
|
+
# sync, keyed by absolute path. Lets the worker detect fence-only
|
|
183
|
+
# rewrites (the linker's `BRAIN_DERIVED_*` section regen) and skip a
|
|
184
|
+
# redundant ``sync_one_file`` that would just re-trigger the same
|
|
185
|
+
# rewrite, looping forever. Only the worker thread touches this dict
|
|
186
|
+
# — no lock is needed for the same reason ``processed`` doesn't
|
|
187
|
+
# take one.
|
|
188
|
+
body_cache: dict[Path, str] = field(default_factory=dict)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def run_watcher(
|
|
192
|
+
conn_factory: Callable[[], psycopg.Connection[Any]],
|
|
193
|
+
*,
|
|
194
|
+
embedder: Embedder,
|
|
195
|
+
config: WatchConfig,
|
|
196
|
+
observer_factory: Callable[[], BaseObserver] | None = None,
|
|
197
|
+
install_signal_handlers: bool = True,
|
|
198
|
+
graph_syncer: GraphSyncer | None = None,
|
|
199
|
+
) -> SyncReport:
|
|
200
|
+
"""Block until SIGINT/SIGTERM, watching ``config.vault_path``.
|
|
201
|
+
|
|
202
|
+
On entry, performs a one-shot full sync so any edits made while the
|
|
203
|
+
watcher was off are picked up. Then starts a watchdog ``Observer``
|
|
204
|
+
plus a worker thread, and parks the main thread on
|
|
205
|
+
``state.stop_event`` until a signal fires.
|
|
206
|
+
|
|
207
|
+
Returns the startup ``SyncReport`` so the CLI can print "watching
|
|
208
|
+
after initial sync: created 3, updated 0…" before parking.
|
|
209
|
+
|
|
210
|
+
``conn_factory`` MUST return a fresh psycopg connection each call;
|
|
211
|
+
the watcher takes ownership of exactly one connection (lives in the
|
|
212
|
+
worker thread for the duration of the watcher) and closes it on
|
|
213
|
+
shutdown. Production passes ``lambda: psycopg.connect(database_url)``
|
|
214
|
+
via the CLI; tests pass a closure that opens the test DB.
|
|
215
|
+
|
|
216
|
+
``observer_factory`` defaults to watchdog's real ``Observer``; tests
|
|
217
|
+
inject a fake to avoid actually touching the OS watcher subsystem.
|
|
218
|
+
|
|
219
|
+
``install_signal_handlers`` defaults to True. Tests set this to False
|
|
220
|
+
so they can drive shutdown via ``state.stop_event`` directly without
|
|
221
|
+
interfering with pytest's own signal handling.
|
|
222
|
+
"""
|
|
223
|
+
state = _WatcherState()
|
|
224
|
+
|
|
225
|
+
# 1. Startup sync. Use a short-lived connection scoped to this call —
|
|
226
|
+
# we don't want to hold a connection while parked on the stop event.
|
|
227
|
+
startup_conn = conn_factory()
|
|
228
|
+
try:
|
|
229
|
+
startup_conn.autocommit = True
|
|
230
|
+
report = sync_vault(
|
|
231
|
+
startup_conn,
|
|
232
|
+
embedder=embedder,
|
|
233
|
+
vault_path=config.vault_path,
|
|
234
|
+
prune=config.prune,
|
|
235
|
+
dry_run=False,
|
|
236
|
+
link_rewrite=config.link_rewrite,
|
|
237
|
+
owner_participants=config.owner_participants,
|
|
238
|
+
graph_syncer=graph_syncer,
|
|
239
|
+
)
|
|
240
|
+
finally:
|
|
241
|
+
startup_conn.close()
|
|
242
|
+
|
|
243
|
+
# 2. Worker thread. Owns the long-lived DB connection and consumes
|
|
244
|
+
# jobs from the queue. ``daemon=True`` so a crashing worker
|
|
245
|
+
# doesn't prevent process exit; we still join below for clean
|
|
246
|
+
# shutdown.
|
|
247
|
+
worker_conn = conn_factory()
|
|
248
|
+
worker_conn.autocommit = True
|
|
249
|
+
worker_thread = threading.Thread(
|
|
250
|
+
target=_worker_loop,
|
|
251
|
+
args=(worker_conn, embedder, config, state, graph_syncer),
|
|
252
|
+
name="brain-vault-watcher-worker",
|
|
253
|
+
daemon=True,
|
|
254
|
+
)
|
|
255
|
+
worker_thread.start()
|
|
256
|
+
|
|
257
|
+
# 3. Observer + handler. The handler is a thin shim that turns
|
|
258
|
+
# watchdog events into ``_Job``s via the debounce path. The
|
|
259
|
+
# Observer runs on its own thread (managed by watchdog).
|
|
260
|
+
factory = observer_factory or Observer
|
|
261
|
+
observer = factory()
|
|
262
|
+
# ``scheduled_dirs`` (shared by reference with the handler) tracks every
|
|
263
|
+
# top-level directory that already has a recursive watch, so the handler
|
|
264
|
+
# can schedule one for a NEW top-level dir created after startup without
|
|
265
|
+
# double-scheduling the ones we set up here.
|
|
266
|
+
scheduled_dirs: set[Path] = set()
|
|
267
|
+
handler = _Handler(
|
|
268
|
+
config=config,
|
|
269
|
+
state=state,
|
|
270
|
+
observer=observer,
|
|
271
|
+
scheduled_dirs=scheduled_dirs,
|
|
272
|
+
)
|
|
273
|
+
# brain (2026-05-08): schedule per non-hidden top-level subdir + root
|
|
274
|
+
# non-recursively, instead of `recursive=True` on the vault root.
|
|
275
|
+
# PollingObserver snapshots every file in the watched tree on every
|
|
276
|
+
# poll cycle; with `recursive=True` on a vault that contains
|
|
277
|
+
# `<vault>/.quartz/node_modules` (~60k+ files) the snapshot work
|
|
278
|
+
# starves the polling thread and edits stop being detected. The
|
|
279
|
+
# event filter (_filter_path) already drops paths under hidden dirs,
|
|
280
|
+
# so excluding them from the WATCHED tree is correct + cheap.
|
|
281
|
+
#
|
|
282
|
+
# brain (2026-07-12, Task 3.1): a NEW top-level dir created after this
|
|
283
|
+
# loop runs is scheduled on the fly by the handler (see
|
|
284
|
+
# _Handler._maybe_watch_new_directory), so files inside it are no longer
|
|
285
|
+
# invisible until restart.
|
|
286
|
+
scheduled_names: list[str] = []
|
|
287
|
+
observer.schedule(handler, str(config.vault_path), recursive=False)
|
|
288
|
+
for child in config.vault_path.iterdir():
|
|
289
|
+
if child.is_dir() and not child.name.startswith("."):
|
|
290
|
+
observer.schedule(handler, str(child), recursive=True)
|
|
291
|
+
# A subdir that vanished between iterdir and resolve leaves the
|
|
292
|
+
# watch above standing; just skip the dedup bookkeeping for it.
|
|
293
|
+
with contextlib.suppress(OSError):
|
|
294
|
+
scheduled_dirs.add(child.resolve())
|
|
295
|
+
scheduled_names.append(child.name)
|
|
296
|
+
logger.info(
|
|
297
|
+
"vault watcher: scoped polling on top-level dirs: %s",
|
|
298
|
+
", ".join(scheduled_names) or "(none)",
|
|
299
|
+
)
|
|
300
|
+
observer.start()
|
|
301
|
+
|
|
302
|
+
# 4. Signal handlers. SIGINT for Ctrl-C, SIGTERM for systemd /
|
|
303
|
+
# process managers. We can only install these from the main
|
|
304
|
+
# thread — Python enforces this — and the CLI calls run_watcher
|
|
305
|
+
# from the main thread, so this is fine. Tests set
|
|
306
|
+
# ``install_signal_handlers=False`` to skip this.
|
|
307
|
+
previous_handlers: dict[signal.Signals, Any] = {}
|
|
308
|
+
if install_signal_handlers:
|
|
309
|
+
def _signal_handler(signum: int, _frame: Any) -> None:
|
|
310
|
+
logger.info("vault watcher: received signal %s, shutting down", signum)
|
|
311
|
+
state.stop_event.set()
|
|
312
|
+
|
|
313
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
314
|
+
previous_handlers[sig] = signal.signal(sig, _signal_handler)
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
# Block until a signal (or test) flips the stop_event.
|
|
318
|
+
state.stop_event.wait()
|
|
319
|
+
finally:
|
|
320
|
+
# Restore previous signal handlers so a follow-up CliRunner /
|
|
321
|
+
# KeyboardInterrupt still behaves as the test runner expects.
|
|
322
|
+
for sig, prev in previous_handlers.items():
|
|
323
|
+
signal.signal(sig, prev)
|
|
324
|
+
|
|
325
|
+
# 5. Shutdown.
|
|
326
|
+
observer.stop()
|
|
327
|
+
observer.join(timeout=5.0)
|
|
328
|
+
|
|
329
|
+
# Drain any in-flight debounce timers: cancel them, then enqueue
|
|
330
|
+
# an immediate job for each path that had a pending timer so the
|
|
331
|
+
# worker still picks up the user's last edit before exiting.
|
|
332
|
+
with state.lock:
|
|
333
|
+
pending_paths = list(state.pending_timers.keys())
|
|
334
|
+
for timer in state.pending_timers.values():
|
|
335
|
+
timer.cancel()
|
|
336
|
+
state.pending_timers.clear()
|
|
337
|
+
for path in pending_paths:
|
|
338
|
+
_enqueue(state, _Job(action="upsert", abs_path=path), config=config)
|
|
339
|
+
|
|
340
|
+
# Sentinel tells the worker its queue is now closed.
|
|
341
|
+
state.jobs.put(None)
|
|
342
|
+
worker_thread.join(timeout=10.0)
|
|
343
|
+
if worker_thread.is_alive():
|
|
344
|
+
# The worker is mid-statement (a sync_vault call, an embed
|
|
345
|
+
# request, etc.) past our 10s patience. Closing the
|
|
346
|
+
# connection from the main thread now would race against
|
|
347
|
+
# the in-flight statement and raise from the worker thread
|
|
348
|
+
# while we're trying to return cleanly. The thread is a
|
|
349
|
+
# daemon — it carries the orphaned connection on its back,
|
|
350
|
+
# and Postgres reaps the backend after its idle timeout.
|
|
351
|
+
logger.warning(
|
|
352
|
+
"vault watcher: worker thread did not exit within 10s — "
|
|
353
|
+
"leaking its connection to avoid a mid-statement close race"
|
|
354
|
+
)
|
|
355
|
+
else:
|
|
356
|
+
try:
|
|
357
|
+
worker_conn.close()
|
|
358
|
+
except psycopg.Error:
|
|
359
|
+
# Connection may already be closed (e.g. server-side timeout) —
|
|
360
|
+
# nothing to recover, just don't crash on shutdown.
|
|
361
|
+
logger.debug(
|
|
362
|
+
"vault watcher: worker connection already closed"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
logger.info(
|
|
366
|
+
"vault watcher: stopped after %d events (%d errors)",
|
|
367
|
+
state.processed,
|
|
368
|
+
state.errors,
|
|
369
|
+
)
|
|
370
|
+
return report
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
# Internal helpers — event classification, debouncing, worker loop.
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class _Handler(FileSystemEventHandler):
|
|
379
|
+
"""Watchdog handler that funnels events into our debounce path.
|
|
380
|
+
|
|
381
|
+
Kept deliberately small: classification + filtering live in
|
|
382
|
+
``_classify_event``, debouncing lives in ``_schedule_debounced``,
|
|
383
|
+
job execution lives in ``_worker_loop``. The handler just glues
|
|
384
|
+
them together.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
def __init__(
|
|
388
|
+
self,
|
|
389
|
+
*,
|
|
390
|
+
config: WatchConfig,
|
|
391
|
+
state: _WatcherState,
|
|
392
|
+
observer: BaseObserver | None = None,
|
|
393
|
+
scheduled_dirs: set[Path] | None = None,
|
|
394
|
+
) -> None:
|
|
395
|
+
self._config = config
|
|
396
|
+
self._state = state
|
|
397
|
+
# ``observer`` + ``scheduled_dirs`` let the handler schedule a
|
|
398
|
+
# recursive watch on a NEW top-level directory created after startup
|
|
399
|
+
# (see :meth:`_maybe_watch_new_directory`). Both are optional so a
|
|
400
|
+
# handler can be constructed standalone in tests; production always
|
|
401
|
+
# passes them from :func:`run_watcher`.
|
|
402
|
+
self._observer = observer
|
|
403
|
+
self._scheduled_dirs = (
|
|
404
|
+
scheduled_dirs if scheduled_dirs is not None else set()
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def on_any_event(self, event: FileSystemEvent) -> None:
|
|
408
|
+
# ``on_any_event`` fires for every event type — we route
|
|
409
|
+
# everything through one classifier rather than per-type
|
|
410
|
+
# handlers so the filtering logic lives in one place.
|
|
411
|
+
try:
|
|
412
|
+
# A brand-new top-level directory (and every file under it) is
|
|
413
|
+
# invisible to the startup-enumerated recursive watches; schedule
|
|
414
|
+
# one on the fly before classifying the (directory) event, which
|
|
415
|
+
# itself has no per-file action.
|
|
416
|
+
self._maybe_watch_new_directory(event)
|
|
417
|
+
classified = _classify_event(event, self._config.vault_path)
|
|
418
|
+
except Exception:
|
|
419
|
+
# Defensive: a misbehaving event must never kill the
|
|
420
|
+
# observer thread. Log and move on.
|
|
421
|
+
logger.exception("vault watcher: classify failed for %r", event)
|
|
422
|
+
return
|
|
423
|
+
if classified is None:
|
|
424
|
+
return
|
|
425
|
+
for action, path, dest in classified:
|
|
426
|
+
_schedule_debounced(self._config, self._state, action, path, dest)
|
|
427
|
+
|
|
428
|
+
def _maybe_watch_new_directory(self, event: FileSystemEvent) -> None:
|
|
429
|
+
"""Schedule a recursive watch for a NEW top-level directory.
|
|
430
|
+
|
|
431
|
+
The vault root is watched non-recursively and per-subdir recursive
|
|
432
|
+
watches are enumerated only once, at startup, so a top-level directory
|
|
433
|
+
created afterwards — and every ``.md`` file under it — would be
|
|
434
|
+
invisible until the watcher restarted. When such a directory is
|
|
435
|
+
created we schedule a recursive watch on it (so future edits inside it
|
|
436
|
+
are seen) and enqueue any ``.md`` files that already exist in it (they
|
|
437
|
+
predate the watch, so the polling observer treats them as baseline and
|
|
438
|
+
never emits a create event for them).
|
|
439
|
+
|
|
440
|
+
Only NEW directories DIRECTLY under the vault root are handled: a
|
|
441
|
+
nested directory is already covered by its top-level ancestor's
|
|
442
|
+
recursive watch, and scheduling a second watch would double-deliver
|
|
443
|
+
every event. Hidden directories (``.git/`` etc.) are skipped, matching
|
|
444
|
+
the startup schedule filter.
|
|
445
|
+
"""
|
|
446
|
+
if self._observer is None:
|
|
447
|
+
return
|
|
448
|
+
if not event.is_directory or event.event_type != "created":
|
|
449
|
+
return
|
|
450
|
+
new_dir = _to_path(event.src_path)
|
|
451
|
+
vault_path = self._config.vault_path
|
|
452
|
+
try:
|
|
453
|
+
resolved = new_dir.resolve()
|
|
454
|
+
relative = resolved.relative_to(vault_path.resolve())
|
|
455
|
+
except (ValueError, OSError):
|
|
456
|
+
return
|
|
457
|
+
# Top-level == exactly one path component directly under the root.
|
|
458
|
+
if len(relative.parts) != 1 or relative.parts[0].startswith("."):
|
|
459
|
+
return
|
|
460
|
+
if resolved in self._scheduled_dirs:
|
|
461
|
+
return
|
|
462
|
+
self._observer.schedule(self, str(new_dir), recursive=True)
|
|
463
|
+
self._scheduled_dirs.add(resolved)
|
|
464
|
+
logger.info(
|
|
465
|
+
"vault watcher: scheduled recursive watch on new top-level dir %s",
|
|
466
|
+
relative.parts[0],
|
|
467
|
+
)
|
|
468
|
+
# Enqueue notes that already exist inside the new dir — they predate
|
|
469
|
+
# the watch and would otherwise never generate a create event.
|
|
470
|
+
try:
|
|
471
|
+
existing = sorted(new_dir.rglob("*.md"))
|
|
472
|
+
except OSError:
|
|
473
|
+
return
|
|
474
|
+
for md in existing:
|
|
475
|
+
filtered = _filter_path(md, vault_path)
|
|
476
|
+
if filtered is not None:
|
|
477
|
+
_schedule_debounced(
|
|
478
|
+
self._config, self._state, "upsert", filtered, None
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _classify_event(
|
|
483
|
+
event: FileSystemEvent, vault_path: Path
|
|
484
|
+
) -> list[tuple[_Action, Path, Path | None]] | None:
|
|
485
|
+
"""Decide what to do with a watchdog event.
|
|
486
|
+
|
|
487
|
+
Returns a list of ``(action, absolute_path, dest_path)`` tuples — usually
|
|
488
|
+
one entry with ``dest_path=None``. A within-vault ``.md`` rename becomes a
|
|
489
|
+
single ``("move", src, dst)`` so the worker can UPDATE the row's
|
|
490
|
+
``vault_path`` in place, preserving the document id and every incoming
|
|
491
|
+
backlink. A move whose destination leaves the vault (or stops being a
|
|
492
|
+
``.md`` file) still decomposes into ``("delete", src, None)`` +
|
|
493
|
+
``("upsert", dst, None)``. Returns ``None`` to skip the event entirely.
|
|
494
|
+
|
|
495
|
+
Note: editors that emulate a rename as an atomic delete+create (and
|
|
496
|
+
cross-device moves) emit no ``FileMovedEvent`` — those arrive as separate
|
|
497
|
+
``deleted`` / ``created`` events and keep the delete+upsert behavior, so a
|
|
498
|
+
heavily-linked note renamed that way still relies on a full
|
|
499
|
+
``brain vault sync`` to restore its incoming backlinks.
|
|
500
|
+
|
|
501
|
+
Filtering rules (kept conservative — the worker can always re-derive
|
|
502
|
+
state from the DB if a real edit slipped past, but we do NOT want to
|
|
503
|
+
waste cycles on every byte-level FSEvent):
|
|
504
|
+
|
|
505
|
+
- directory events are skipped (we sync per-file)
|
|
506
|
+
- non-``.md`` paths are skipped (editor temp files, ``.DS_Store``,
|
|
507
|
+
attachments, etc.)
|
|
508
|
+
- paths with any hidden component (starts with ``.``) are skipped —
|
|
509
|
+
catches ``.git/``, ``.obsidian/``, VSCode's ``.vscode/`` etc.
|
|
510
|
+
- paths under ``_templates/`` or ``_attachments/`` are skipped —
|
|
511
|
+
the sync engine itself ignores these so syncing them is wasted work
|
|
512
|
+
"""
|
|
513
|
+
if event.is_directory:
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
event_type = event.event_type # 'created' | 'modified' | 'deleted' | 'moved'
|
|
517
|
+
if event_type == "moved":
|
|
518
|
+
# Watchdog's FileMovedEvent exposes both src and dest. Path values
|
|
519
|
+
# are typed as ``bytes | str`` upstream — decode bytes paths via
|
|
520
|
+
# ``os.fsdecode`` so Path() accepts them on every platform.
|
|
521
|
+
src = _filter_path(_to_path(event.src_path), vault_path)
|
|
522
|
+
dest_attr = getattr(event, "dest_path", None)
|
|
523
|
+
dst = _filter_path(_to_path(dest_attr), vault_path) if dest_attr else None
|
|
524
|
+
if src is not None and dst is not None:
|
|
525
|
+
# Within-vault markdown rename: thread it as a single ``move`` so
|
|
526
|
+
# the worker UPDATEs ``vault_path`` in place instead of
|
|
527
|
+
# delete-cascading (then re-inserting) the row, which would wipe
|
|
528
|
+
# every incoming backlink pointing at the moved doc.
|
|
529
|
+
return [("move", src, dst)]
|
|
530
|
+
results: list[tuple[_Action, Path, Path | None]] = []
|
|
531
|
+
if src is not None:
|
|
532
|
+
results.append(("delete", src, None))
|
|
533
|
+
if dst is not None:
|
|
534
|
+
results.append(("upsert", dst, None))
|
|
535
|
+
return results or None
|
|
536
|
+
|
|
537
|
+
path = _filter_path(_to_path(event.src_path), vault_path)
|
|
538
|
+
if path is None:
|
|
539
|
+
return None
|
|
540
|
+
action: _Action = "delete" if event_type == "deleted" else "upsert"
|
|
541
|
+
return [(action, path, None)]
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _to_path(raw: str | bytes) -> Path:
|
|
545
|
+
"""Coerce a watchdog event path (``bytes | str``) into a ``Path``.
|
|
546
|
+
|
|
547
|
+
Watchdog declares event paths as ``bytes | str`` because some
|
|
548
|
+
backends (e.g. inotify on Linux) can surface raw bytes. We decode
|
|
549
|
+
eagerly via ``os.fsdecode`` so the rest of the watcher uses
|
|
550
|
+
``pathlib.Path`` exclusively.
|
|
551
|
+
"""
|
|
552
|
+
if isinstance(raw, bytes):
|
|
553
|
+
return Path(os.fsdecode(raw))
|
|
554
|
+
return Path(raw)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _filter_path(path: Path, vault_path: Path) -> Path | None:
|
|
558
|
+
"""Return the absolute path if it's a syncable ``.md`` file, else None.
|
|
559
|
+
|
|
560
|
+
The full filter ladder lives here so ``_classify_event`` (which
|
|
561
|
+
handles move events with two paths) can reuse it without
|
|
562
|
+
duplication.
|
|
563
|
+
"""
|
|
564
|
+
abs_path = path if path.is_absolute() else (vault_path / path)
|
|
565
|
+
try:
|
|
566
|
+
relative = abs_path.resolve().relative_to(vault_path.resolve())
|
|
567
|
+
except ValueError:
|
|
568
|
+
# Outside the vault — should not happen since the Observer
|
|
569
|
+
# watches inside the vault, but defensive.
|
|
570
|
+
return None
|
|
571
|
+
except OSError:
|
|
572
|
+
# ``resolve()`` can fail on a vanished file (deleted events).
|
|
573
|
+
# Fall back to lexical comparison: strip the vault prefix
|
|
574
|
+
# without touching the FS.
|
|
575
|
+
try:
|
|
576
|
+
relative = abs_path.relative_to(vault_path)
|
|
577
|
+
except ValueError:
|
|
578
|
+
return None
|
|
579
|
+
if abs_path.suffix.lower() != ".md":
|
|
580
|
+
return None
|
|
581
|
+
parts = relative.parts
|
|
582
|
+
if not parts:
|
|
583
|
+
return None
|
|
584
|
+
# Hidden directories anywhere in the path — covers .git, .obsidian,
|
|
585
|
+
# editor scratch dirs, etc.
|
|
586
|
+
if any(part.startswith(".") for part in parts):
|
|
587
|
+
return None
|
|
588
|
+
first = parts[0]
|
|
589
|
+
if first in {"_templates", "_attachments"}:
|
|
590
|
+
return None
|
|
591
|
+
return abs_path
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _schedule_debounced(
|
|
595
|
+
config: WatchConfig,
|
|
596
|
+
state: _WatcherState,
|
|
597
|
+
action: _Action,
|
|
598
|
+
path: Path,
|
|
599
|
+
dest: Path | None = None,
|
|
600
|
+
) -> None:
|
|
601
|
+
"""Cancel any pending timer for ``path`` and start a new one.
|
|
602
|
+
|
|
603
|
+
Each new event for the same path resets the debounce timer to
|
|
604
|
+
``config.debounce_ms`` from now. Once the path goes ``debounce_ms``
|
|
605
|
+
without a new event, the timer fires and enqueues a job. ``dest`` is the
|
|
606
|
+
rename destination for a ``move`` action (``None`` otherwise); the
|
|
607
|
+
debounce is keyed on ``path`` (the source), so repeated moves of the same
|
|
608
|
+
source coalesce and the last one's destination wins — the desired
|
|
609
|
+
outcome for a burst of rename events.
|
|
610
|
+
|
|
611
|
+
If the pending-timer dict overflows (a misbehaving editor or a
|
|
612
|
+
bulk-replace operation), we fall back to "trigger an immediate full
|
|
613
|
+
sync, then clear the buffer" — bounded resource usage trumps
|
|
614
|
+
incremental optimization here.
|
|
615
|
+
"""
|
|
616
|
+
delay = max(config.debounce_ms, 0) / 1000.0
|
|
617
|
+
|
|
618
|
+
def _fire() -> None:
|
|
619
|
+
with state.lock:
|
|
620
|
+
state.pending_timers.pop(path, None)
|
|
621
|
+
_enqueue(
|
|
622
|
+
state,
|
|
623
|
+
_Job(action=action, abs_path=path, dest_path=dest),
|
|
624
|
+
config=config,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
with state.lock:
|
|
628
|
+
existing = state.pending_timers.pop(path, None)
|
|
629
|
+
if existing is not None:
|
|
630
|
+
existing.cancel()
|
|
631
|
+
if len(state.pending_timers) >= _MAX_PENDING:
|
|
632
|
+
# Buffer overflow → drop everything, queue a full-vault sync.
|
|
633
|
+
for timer in state.pending_timers.values():
|
|
634
|
+
timer.cancel()
|
|
635
|
+
state.pending_timers.clear()
|
|
636
|
+
logger.warning(
|
|
637
|
+
"vault watcher: debounce buffer overflow (%d pending) — "
|
|
638
|
+
"scheduling full sync",
|
|
639
|
+
_MAX_PENDING,
|
|
640
|
+
)
|
|
641
|
+
_enqueue(
|
|
642
|
+
state,
|
|
643
|
+
_Job(action="upsert", abs_path=config.vault_path),
|
|
644
|
+
config=config,
|
|
645
|
+
)
|
|
646
|
+
return
|
|
647
|
+
timer = threading.Timer(delay, _fire)
|
|
648
|
+
timer.daemon = True
|
|
649
|
+
state.pending_timers[path] = timer
|
|
650
|
+
timer.start()
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _enqueue(
|
|
654
|
+
state: _WatcherState,
|
|
655
|
+
job: _Job,
|
|
656
|
+
*,
|
|
657
|
+
config: WatchConfig,
|
|
658
|
+
) -> None:
|
|
659
|
+
"""Push a job onto the worker queue, after the optional test hook."""
|
|
660
|
+
if config.on_event is not None:
|
|
661
|
+
try:
|
|
662
|
+
config.on_event(job.action, job.abs_path)
|
|
663
|
+
except Exception:
|
|
664
|
+
logger.exception("vault watcher: on_event hook raised")
|
|
665
|
+
state.jobs.put(job)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _worker_loop(
|
|
669
|
+
conn: psycopg.Connection[Any],
|
|
670
|
+
embedder: Embedder,
|
|
671
|
+
config: WatchConfig,
|
|
672
|
+
state: _WatcherState,
|
|
673
|
+
graph_syncer: GraphSyncer | None = None,
|
|
674
|
+
) -> None:
|
|
675
|
+
"""Consume jobs serially. Owns the DB connection.
|
|
676
|
+
|
|
677
|
+
Four classes of work, distinguished by ``job.action`` / ``job.abs_path``:
|
|
678
|
+
|
|
679
|
+
1. ``upsert`` of a regular file → ``sync_one_file`` (preceded by a
|
|
680
|
+
fence-only-write check; see :func:`_is_fence_only_write`)
|
|
681
|
+
2. ``delete`` of a regular file → DB row removal + link cleanup
|
|
682
|
+
(we don't touch disk; the file is already gone) plus body-cache
|
|
683
|
+
eviction so a re-creation event hits the cold-start path
|
|
684
|
+
3. ``move`` of a regular file → ``_handle_move``: UPDATE the row's
|
|
685
|
+
``vault_path`` in place (preserving the document id and every
|
|
686
|
+
incoming backlink) then upsert the destination
|
|
687
|
+
4. ``upsert`` of the vault root path → full ``sync_vault`` (the
|
|
688
|
+
overflow-recovery path) plus full body-cache flush so subsequent
|
|
689
|
+
per-file events re-prime the cache from the post-sync state
|
|
690
|
+
|
|
691
|
+
Each call is wrapped in try/except so a single-file failure logs
|
|
692
|
+
but doesn't kill the worker — the watcher should be self-healing.
|
|
693
|
+
"""
|
|
694
|
+
while True:
|
|
695
|
+
job = state.jobs.get()
|
|
696
|
+
if job is None:
|
|
697
|
+
return
|
|
698
|
+
try:
|
|
699
|
+
if job.abs_path == config.vault_path and job.action == "upsert":
|
|
700
|
+
# Overflow-recovery: do a full sync.
|
|
701
|
+
sync_vault(
|
|
702
|
+
conn,
|
|
703
|
+
embedder=embedder,
|
|
704
|
+
vault_path=config.vault_path,
|
|
705
|
+
prune=False, # never auto-prune in watch mode
|
|
706
|
+
dry_run=False,
|
|
707
|
+
link_rewrite=config.link_rewrite,
|
|
708
|
+
owner_participants=config.owner_participants,
|
|
709
|
+
graph_syncer=graph_syncer,
|
|
710
|
+
)
|
|
711
|
+
# The full sync may have rewritten any number of fences;
|
|
712
|
+
# the cache entries from before the overflow window are
|
|
713
|
+
# now stale. Clearing forces the next per-file event to
|
|
714
|
+
# take the cold-start path (one extra sync per file,
|
|
715
|
+
# then dedup resumes) — safer than a half-stale map.
|
|
716
|
+
state.body_cache.clear()
|
|
717
|
+
elif job.action == "move" and job.dest_path is not None:
|
|
718
|
+
# Within-vault rename: UPDATE vault_path in place so the
|
|
719
|
+
# document id (and every incoming backlink) survives, then
|
|
720
|
+
# refresh the destination. See :func:`_handle_move`.
|
|
721
|
+
_handle_move(
|
|
722
|
+
conn,
|
|
723
|
+
embedder=embedder,
|
|
724
|
+
config=config,
|
|
725
|
+
state=state,
|
|
726
|
+
src_abs=job.abs_path,
|
|
727
|
+
dst_abs=job.dest_path,
|
|
728
|
+
graph_syncer=graph_syncer,
|
|
729
|
+
)
|
|
730
|
+
elif job.action == "delete":
|
|
731
|
+
# Defensive re-stat: a "deleted" fsevent can fire spuriously
|
|
732
|
+
# when a file is atomically replaced. ``_atomic.atomic_write_text``
|
|
733
|
+
# does ``os.replace(tmp, path)`` (used by ``rewrite_tags`` for
|
|
734
|
+
# ``brain tag`` and by the sync engine itself), and on macOS
|
|
735
|
+
# APFS the rename can surface as a ``deleted`` on the original
|
|
736
|
+
# path with the follow-up ``created`` dropped or coalesced by
|
|
737
|
+
# watchdog. If we trust the action label blindly we'd
|
|
738
|
+
# ``DELETE FROM documents`` for a perfectly intact file —
|
|
739
|
+
# discovered live on 2026-05-01 by ``brain tag`` round-trip.
|
|
740
|
+
# The on-disk file is the source of truth here: re-stat
|
|
741
|
+
# before deletion. If the file is still there, treat as a
|
|
742
|
+
# plain upsert (which will reconcile any frontmatter changes
|
|
743
|
+
# the producer just made).
|
|
744
|
+
if job.abs_path.exists():
|
|
745
|
+
logger.debug(
|
|
746
|
+
"vault watcher: ignoring stale delete for %s "
|
|
747
|
+
"(file still exists — likely an atomic-replace event)",
|
|
748
|
+
job.abs_path,
|
|
749
|
+
)
|
|
750
|
+
if _is_fence_only_write(state, job.abs_path):
|
|
751
|
+
state.skipped_fence_only += 1
|
|
752
|
+
else:
|
|
753
|
+
report = sync_one_file(
|
|
754
|
+
conn,
|
|
755
|
+
embedder=embedder,
|
|
756
|
+
vault_path=config.vault_path,
|
|
757
|
+
file_path=job.abs_path,
|
|
758
|
+
link_rewrite=config.link_rewrite,
|
|
759
|
+
owner_participants=config.owner_participants,
|
|
760
|
+
graph_syncer=graph_syncer,
|
|
761
|
+
)
|
|
762
|
+
_cache_after_sync(state, job.abs_path, report)
|
|
763
|
+
else:
|
|
764
|
+
_handle_delete(
|
|
765
|
+
conn,
|
|
766
|
+
job.abs_path,
|
|
767
|
+
config.vault_path,
|
|
768
|
+
graph_syncer=graph_syncer,
|
|
769
|
+
)
|
|
770
|
+
# Drop the cache entry so a future creation event for
|
|
771
|
+
# the same path is treated as cold-start, not a
|
|
772
|
+
# spurious fence-only no-op.
|
|
773
|
+
state.body_cache.pop(job.abs_path, None)
|
|
774
|
+
else:
|
|
775
|
+
if not job.abs_path.exists():
|
|
776
|
+
# Symmetric to the stale-delete guard above: an
|
|
777
|
+
# ``upsert`` event can survive past the file's
|
|
778
|
+
# actual existence, e.g. a transient editor temp
|
|
779
|
+
# file, a ``brain note new`` followed immediately
|
|
780
|
+
# by ``brain rm``, or any tool that creates +
|
|
781
|
+
# removes within the debounce window. Without this
|
|
782
|
+
# guard ``sync_one_file`` would raise
|
|
783
|
+
# ``FileNotFoundError``, the outer ``except`` would
|
|
784
|
+
# bump ``state.errors``, and the shutdown report
|
|
785
|
+
# would falsely flag a phantom failure. Skip the
|
|
786
|
+
# job entirely (``continue``) so neither
|
|
787
|
+
# ``processed`` nor ``errors`` advances — there
|
|
788
|
+
# was no real work to count.
|
|
789
|
+
logger.debug(
|
|
790
|
+
"vault watcher: ignoring stale upsert for %s "
|
|
791
|
+
"(file no longer exists — likely a transient "
|
|
792
|
+
"create+delete)",
|
|
793
|
+
job.abs_path,
|
|
794
|
+
)
|
|
795
|
+
continue
|
|
796
|
+
if _is_fence_only_write(state, job.abs_path):
|
|
797
|
+
state.skipped_fence_only += 1
|
|
798
|
+
logger.debug(
|
|
799
|
+
"vault watcher: skipping fence-only write for %s",
|
|
800
|
+
job.abs_path,
|
|
801
|
+
)
|
|
802
|
+
else:
|
|
803
|
+
report = sync_one_file(
|
|
804
|
+
conn,
|
|
805
|
+
embedder=embedder,
|
|
806
|
+
vault_path=config.vault_path,
|
|
807
|
+
file_path=job.abs_path,
|
|
808
|
+
link_rewrite=config.link_rewrite,
|
|
809
|
+
owner_participants=config.owner_participants,
|
|
810
|
+
graph_syncer=graph_syncer,
|
|
811
|
+
)
|
|
812
|
+
# Prime the cache from the baseline sync indexed rather
|
|
813
|
+
# than a fresh disk read: the fence the linker rewrote is
|
|
814
|
+
# excluded by strip_fence, and a user edit that raced
|
|
815
|
+
# sync's write must NOT be baselined in (which would mask
|
|
816
|
+
# its follow-up event as fence-only). See _cache_after_sync.
|
|
817
|
+
_cache_after_sync(state, job.abs_path, report)
|
|
818
|
+
state.processed += 1
|
|
819
|
+
except Exception:
|
|
820
|
+
state.errors += 1
|
|
821
|
+
logger.exception(
|
|
822
|
+
"vault watcher: worker failed on %s %s",
|
|
823
|
+
job.action,
|
|
824
|
+
job.abs_path,
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _is_fence_only_write(state: _WatcherState, abs_path: Path) -> bool:
|
|
829
|
+
"""Return True iff ``abs_path`` differs from cache only inside the fence.
|
|
830
|
+
|
|
831
|
+
Reads the file at ``abs_path``, strips its ``BRAIN_DERIVED_*`` fence
|
|
832
|
+
region, and compares the result to the cached fence-stripped body
|
|
833
|
+
recorded after the most recent successful sync. Equal means the
|
|
834
|
+
write was a fence regeneration (`docs/specs/2026-04-30-derived-edges-in-bodies-design.md`)
|
|
835
|
+
and re-running ``sync_one_file`` would just produce the same rewrite,
|
|
836
|
+
looping forever — caller should skip.
|
|
837
|
+
|
|
838
|
+
Returns ``False`` (i.e. don't skip; run sync) when:
|
|
839
|
+
|
|
840
|
+
- There's no cache entry for ``abs_path`` (cold start — spec
|
|
841
|
+
requires first-sight syncs to run).
|
|
842
|
+
- The file can't be read (vanished between debounce and worker
|
|
843
|
+
pickup, permissions error, etc.) — let ``sync_one_file`` see the
|
|
844
|
+
same problem and report it through the existing error path.
|
|
845
|
+
- The fence-stripped body genuinely changed.
|
|
846
|
+
"""
|
|
847
|
+
try:
|
|
848
|
+
current = abs_path.read_text(encoding="utf-8")
|
|
849
|
+
except OSError:
|
|
850
|
+
return False
|
|
851
|
+
cached = state.body_cache.get(abs_path)
|
|
852
|
+
if cached is None:
|
|
853
|
+
return False
|
|
854
|
+
return cached == strip_fence(current)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _refresh_body_cache(state: _WatcherState, abs_path: Path) -> None:
|
|
858
|
+
"""Update the body cache for ``abs_path`` from its current on-disk content.
|
|
859
|
+
|
|
860
|
+
Called after every successful ``sync_one_file`` so the cached
|
|
861
|
+
fence-stripped body reflects whatever the linker just rendered. The
|
|
862
|
+
next watch event for the same path can then detect a fence-only
|
|
863
|
+
rewrite and short-circuit.
|
|
864
|
+
|
|
865
|
+
If the file vanished between sync and this read, the stale entry is
|
|
866
|
+
evicted rather than left out of sync with disk.
|
|
867
|
+
"""
|
|
868
|
+
try:
|
|
869
|
+
current = abs_path.read_text(encoding="utf-8")
|
|
870
|
+
except OSError:
|
|
871
|
+
state.body_cache.pop(abs_path, None)
|
|
872
|
+
return
|
|
873
|
+
state.body_cache[abs_path] = strip_fence(current)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _cache_after_sync(
|
|
877
|
+
state: _WatcherState,
|
|
878
|
+
abs_path: Path,
|
|
879
|
+
report: SyncReport | None,
|
|
880
|
+
) -> None:
|
|
881
|
+
"""Prime the fence-only-write cache after a per-file sync (Task 3.2).
|
|
882
|
+
|
|
883
|
+
Prefers the fence-stripped baseline sync reported (``report.body_baseline``
|
|
884
|
+
— the content it actually INDEXED) over a fresh disk read. A disk read here
|
|
885
|
+
would baseline a racing user edit that landed between sync's write and this
|
|
886
|
+
call, so the follow-up event for that edit would be misclassified as
|
|
887
|
+
fence-only and skipped — the DB would never catch up until a full sync.
|
|
888
|
+
|
|
889
|
+
Falls back to re-reading disk when sync did not report a baseline (a
|
|
890
|
+
skipped documentation file, or a test double that returns no report), which
|
|
891
|
+
preserves the pre-fix behavior for those cases.
|
|
892
|
+
"""
|
|
893
|
+
if report is not None and report.body_baseline is not None:
|
|
894
|
+
state.body_cache[abs_path] = report.body_baseline
|
|
895
|
+
else:
|
|
896
|
+
_refresh_body_cache(state, abs_path)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _vault_relative_posix(abs_path: Path, vault_path: Path) -> str | None:
|
|
900
|
+
"""Return ``abs_path`` relative to the vault as a POSIX string, or None.
|
|
901
|
+
|
|
902
|
+
Mirrors the ``resolve()``-then-lexical-fallback ladder used elsewhere in
|
|
903
|
+
the watcher so a vanished path (a delete or move source, already gone
|
|
904
|
+
from disk) still yields the correct relative key without touching the FS.
|
|
905
|
+
Returns ``None`` when the path is outside the vault.
|
|
906
|
+
"""
|
|
907
|
+
try:
|
|
908
|
+
return abs_path.relative_to(vault_path.resolve()).as_posix()
|
|
909
|
+
except ValueError:
|
|
910
|
+
try:
|
|
911
|
+
return abs_path.relative_to(vault_path).as_posix()
|
|
912
|
+
except ValueError:
|
|
913
|
+
return None
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _handle_delete(
|
|
917
|
+
conn: psycopg.Connection[Any],
|
|
918
|
+
abs_path: Path,
|
|
919
|
+
vault_path: Path,
|
|
920
|
+
*,
|
|
921
|
+
graph_syncer: GraphSyncer | None = None,
|
|
922
|
+
) -> None:
|
|
923
|
+
"""Remove the vault-tier ``documents`` row for ``abs_path``.
|
|
924
|
+
|
|
925
|
+
Why not call ``sync_vault(prune=True)``? Two reasons: (1) we'd be
|
|
926
|
+
doing a full walk for a single deletion, which is wasteful, and (2)
|
|
927
|
+
auto-prune in ``sync_vault`` only fires when *every* file's been
|
|
928
|
+
walked — for a focused delete event we want to act on just this
|
|
929
|
+
path. So instead we look up the row by ``vault_path`` and delete
|
|
930
|
+
it; ``ON DELETE CASCADE`` handles chunks + outgoing links.
|
|
931
|
+
|
|
932
|
+
Ingested-tier rows are intentionally never auto-deleted in watch
|
|
933
|
+
mode — those mirror an upstream system (Krisp, Slack, Gmail), and a
|
|
934
|
+
transient ``.md`` removal under ``_ingested/`` shouldn't blow away
|
|
935
|
+
the canonical DB record. The user can always re-export.
|
|
936
|
+
|
|
937
|
+
Idempotent: a delete event for a path with no DB row is a no-op.
|
|
938
|
+
|
|
939
|
+
Schema-drift defense: the migration ``003_vault_model.sql`` declares
|
|
940
|
+
a unique partial index ``documents_vault_path_idx`` on
|
|
941
|
+
``vault_path`` WHERE ``vault_path IS NOT NULL``, so this DELETE
|
|
942
|
+
matches at most one row in normal operation. We still run the SQL
|
|
943
|
+
with ``RETURNING id`` and log a loud ``logger.error`` if the result
|
|
944
|
+
set has more than one row — observability for a future schema bug
|
|
945
|
+
(manual ``ALTER``, dropped index, migration regression). The
|
|
946
|
+
surplus rows are still deleted (we don't raise) so the user isn't
|
|
947
|
+
left with a half-finished delete and orphaned chunks; the error log
|
|
948
|
+
is what surfaces the invariant break.
|
|
949
|
+
"""
|
|
950
|
+
relative = _vault_relative_posix(abs_path, vault_path)
|
|
951
|
+
if relative is None:
|
|
952
|
+
logger.debug(
|
|
953
|
+
"vault watcher: delete event for %s outside vault — skipping",
|
|
954
|
+
abs_path,
|
|
955
|
+
)
|
|
956
|
+
return
|
|
957
|
+
deleted = conn.execute(
|
|
958
|
+
"DELETE FROM documents WHERE kind = 'vault' AND vault_path = %s "
|
|
959
|
+
"RETURNING id",
|
|
960
|
+
(relative,),
|
|
961
|
+
).fetchall()
|
|
962
|
+
if len(deleted) > 1:
|
|
963
|
+
logger.error(
|
|
964
|
+
"vault watcher: deleted %d rows for vault_path=%s — "
|
|
965
|
+
"schema uniqueness invariant broken (expected 0 or 1)",
|
|
966
|
+
len(deleted),
|
|
967
|
+
relative,
|
|
968
|
+
)
|
|
969
|
+
# Wave G1-c: drop each deleted doc from the people graph (best-effort /
|
|
970
|
+
# never-raises; no-op when graph sync is disabled or AGE is absent). The
|
|
971
|
+
# worker connection is autocommit, so the DELETE above already committed.
|
|
972
|
+
if graph_syncer is not None:
|
|
973
|
+
for row in deleted:
|
|
974
|
+
graph_syncer.remove(conn, str(row[0]))
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _handle_move(
|
|
978
|
+
conn: psycopg.Connection[Any],
|
|
979
|
+
*,
|
|
980
|
+
embedder: Embedder,
|
|
981
|
+
config: WatchConfig,
|
|
982
|
+
state: _WatcherState,
|
|
983
|
+
src_abs: Path,
|
|
984
|
+
dst_abs: Path,
|
|
985
|
+
graph_syncer: GraphSyncer | None = None,
|
|
986
|
+
) -> None:
|
|
987
|
+
"""Handle a within-vault markdown rename WITHOUT destroying incoming links.
|
|
988
|
+
|
|
989
|
+
The old classifier decomposed a move into ``delete(src) + upsert(dst)``.
|
|
990
|
+
The delete ran ``DELETE FROM documents WHERE vault_path=src`` whose
|
|
991
|
+
``ON DELETE CASCADE`` wiped every ``links`` row pointing AT the moved doc
|
|
992
|
+
(its incoming backlinks); the follow-up upsert only restored the doc's
|
|
993
|
+
OUTGOING links. Renaming a heavily-linked note therefore silently
|
|
994
|
+
orphaned every backlink until the next full ``brain vault sync``.
|
|
995
|
+
|
|
996
|
+
The fix mirrors the full-sync rename semantics: UPDATE the row's
|
|
997
|
+
``vault_path`` in place so the document id — and thus every incoming
|
|
998
|
+
``links`` row — survives, then run the normal per-file upsert on the
|
|
999
|
+
destination to refresh content / tags / chunks / outgoing links / mirror
|
|
1000
|
+
bookkeeping (it matches the row by frontmatter id and updates it in
|
|
1001
|
+
place). The UPDATE is a single statement, so this keeps the DB write
|
|
1002
|
+
window no larger than the old DELETE — it does not widen the historically
|
|
1003
|
+
deadlock-prone lock footprint.
|
|
1004
|
+
|
|
1005
|
+
A move whose destination has vanished by the time the worker runs
|
|
1006
|
+
(a move-then-delete inside the debounce window) can't be applied in
|
|
1007
|
+
place — there's no file to sync, and pointing ``vault_path`` at a missing
|
|
1008
|
+
file would strand the row. In that case we fall back to a plain delete of
|
|
1009
|
+
the source row, symmetric to the stale-upsert existence guard in the
|
|
1010
|
+
worker loop.
|
|
1011
|
+
"""
|
|
1012
|
+
if not dst_abs.exists():
|
|
1013
|
+
_handle_delete(
|
|
1014
|
+
conn, src_abs, config.vault_path, graph_syncer=graph_syncer
|
|
1015
|
+
)
|
|
1016
|
+
state.body_cache.pop(src_abs, None)
|
|
1017
|
+
state.body_cache.pop(dst_abs, None)
|
|
1018
|
+
return
|
|
1019
|
+
|
|
1020
|
+
src_rel = _vault_relative_posix(src_abs, config.vault_path)
|
|
1021
|
+
dst_rel = _vault_relative_posix(dst_abs, config.vault_path)
|
|
1022
|
+
if src_rel is not None and dst_rel is not None and src_rel != dst_rel:
|
|
1023
|
+
# Preserve the document id (and every incoming ``links`` row) by
|
|
1024
|
+
# moving ``vault_path`` in place instead of delete + re-insert. The
|
|
1025
|
+
# worker connection is autocommit, so this commits immediately — the
|
|
1026
|
+
# same single-statement footprint as ``_handle_delete``'s DELETE.
|
|
1027
|
+
conn.execute(
|
|
1028
|
+
"UPDATE documents SET vault_path = %s "
|
|
1029
|
+
"WHERE kind = 'vault' AND vault_path = %s",
|
|
1030
|
+
(dst_rel, src_rel),
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
# Refresh content / mirror bookkeeping on the destination. This matches
|
|
1034
|
+
# the row by frontmatter id and updates it in place (full-sync
|
|
1035
|
+
# semantics), re-materializing the moved doc's OUTGOING links and retrying
|
|
1036
|
+
# any previously-unresolved refs scoped to it.
|
|
1037
|
+
report = sync_one_file(
|
|
1038
|
+
conn,
|
|
1039
|
+
embedder=embedder,
|
|
1040
|
+
vault_path=config.vault_path,
|
|
1041
|
+
file_path=dst_abs,
|
|
1042
|
+
link_rewrite=config.link_rewrite,
|
|
1043
|
+
owner_participants=config.owner_participants,
|
|
1044
|
+
graph_syncer=graph_syncer,
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
# The source path's cached fence-stripped body is now stale (file gone);
|
|
1048
|
+
# prime the destination from the baseline sync indexed (not a fresh disk
|
|
1049
|
+
# read) so a racing edit isn't masked while the follow-up event from
|
|
1050
|
+
# sync's own write is still recognized as fence-only.
|
|
1051
|
+
state.body_cache.pop(src_abs, None)
|
|
1052
|
+
_cache_after_sync(state, dst_abs, report)
|