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
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
"""Watchdog-driven Quartz rebuild trigger.
|
|
2
|
+
|
|
3
|
+
Watches the vault for changes and runs :func:`build_and_swap` after a
|
|
4
|
+
quiet period. The architecture is the same shape as
|
|
5
|
+
``brain.vault.watch`` (Observer thread → handler → debounce timer →
|
|
6
|
+
single-worker serialization) but stripped to one job type — "rebuild
|
|
7
|
+
the site" — because that's the only thing the wiki layer does.
|
|
8
|
+
|
|
9
|
+
Design notes:
|
|
10
|
+
|
|
11
|
+
- Only one build at a time. A ``threading.Lock`` guards
|
|
12
|
+
``build_and_swap`` so a slow build can't pile up.
|
|
13
|
+
- During a build, additional events accumulate in ``pending_batch``
|
|
14
|
+
and drain into the next build after the in-flight build finishes.
|
|
15
|
+
This means N events fired during one build → exactly one rebuild
|
|
16
|
+
after (carrying all accumulated paths), not N.
|
|
17
|
+
- Editor scratch files (``.#foo``, ``foo~``) and the ``.git`` /
|
|
18
|
+
``.quartz`` subtrees are filtered before debouncing — there's no
|
|
19
|
+
point waking the build for our own output (loop) or for files
|
|
20
|
+
Quartz won't render anyway.
|
|
21
|
+
- ``observer_factory`` is the test seam: production passes ``None`` and
|
|
22
|
+
watchdog's real ``Observer`` is used; tests pass a fake observer
|
|
23
|
+
with an ``inject(event)`` method to drive the handler synchronously.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import contextlib
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
39
|
+
from watchdog.observers.api import BaseObserver
|
|
40
|
+
from watchdog.observers.polling import PollingObserver as Observer
|
|
41
|
+
|
|
42
|
+
# brain (2026-05-08): use PollingObserver instead of watchdog's default
|
|
43
|
+
# Observer (FSEvents backend). The FSEvents backend silently stops
|
|
44
|
+
# delivering events on Python 3.13 — Observer starts cleanly, stays
|
|
45
|
+
# alive, but never invokes the handler. See identical comment in
|
|
46
|
+
# brain/vault/watch.py for the diagnosis.
|
|
47
|
+
from .build_partial import BrainWikiPartialBuildError, run_build_partial
|
|
48
|
+
from .build_swap import BuildResult, _replace_vault_path, build_and_swap
|
|
49
|
+
from .edit_classifier import EditClassification, classify_edit
|
|
50
|
+
from .fastpath_state import FastpathState, FastpathStateError, write_state
|
|
51
|
+
from .slug import slugify_source_path
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
# Fast-path routing enabled by default. Set ``BRAIN_FASTPATH_ENABLED=false``
|
|
56
|
+
# (or ``0`` / ``no``) at process startup to force every debounced edit through
|
|
57
|
+
# the full ``build_and_swap`` path — useful for bisecting routing regressions
|
|
58
|
+
# without restarting Python. Read once at module load so the value is stable
|
|
59
|
+
# for the lifetime of the watcher process.
|
|
60
|
+
_FASTPATH_ENABLED: bool = os.environ.get("BRAIN_FASTPATH_ENABLED", "true").lower() not in (
|
|
61
|
+
"false",
|
|
62
|
+
"0",
|
|
63
|
+
"no",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Subdirectories under the vault we never rebuild for. ``.git`` is
|
|
68
|
+
# obvious; ``.quartz`` is critical because that's where *we* write the
|
|
69
|
+
# build output — without this the watcher would loop on its own writes.
|
|
70
|
+
_IGNORED_TOP_DIRS: frozenset[str] = frozenset({".git", ".quartz"})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_single_md_file(batch: frozenset[Path]) -> bool:
|
|
74
|
+
"""Return True iff ``batch`` contains exactly one Markdown (``.md``) file.
|
|
75
|
+
|
|
76
|
+
The fast path only handles single-file Markdown edits — multi-file
|
|
77
|
+
batches (including mixed batches of ``.md`` + other types) always route
|
|
78
|
+
to the full ``build_and_swap`` path. Non-``.md`` files (images, CSS,
|
|
79
|
+
JavaScript assets) are similarly never eligible for partial emit.
|
|
80
|
+
"""
|
|
81
|
+
if len(batch) != 1:
|
|
82
|
+
return False
|
|
83
|
+
return next(iter(batch)).suffix.lower() == ".md"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class _WatcherState:
|
|
88
|
+
"""Mutable state shared between Observer thread and timer thread.
|
|
89
|
+
|
|
90
|
+
Every field is touched only while holding ``lock``, with two
|
|
91
|
+
intentional exceptions: ``stop_event`` is a thread-safe Event
|
|
92
|
+
(its own lock), and ``timer`` itself is replaced under ``lock``
|
|
93
|
+
but the cancel/start methods are safe to call from outside.
|
|
94
|
+
|
|
95
|
+
Batch queue design (T6b):
|
|
96
|
+
|
|
97
|
+
``current_batch`` accumulates paths during the debounce window (i.e.
|
|
98
|
+
between event arrival and _fire). When _fire runs and no build is in
|
|
99
|
+
flight, ``current_batch`` is atomically drained into the build call.
|
|
100
|
+
|
|
101
|
+
When _fire runs while a build IS in flight, paths move into
|
|
102
|
+
``pending_batch`` instead. After the in-flight build finishes,
|
|
103
|
+
``_drain_pending`` drains ``pending_batch`` into a follow-up build.
|
|
104
|
+
This ensures no path is ever dropped — contrast the old ``pending:
|
|
105
|
+
bool`` design where only a "there is something pending" flag was kept
|
|
106
|
+
and the actual paths were lost.
|
|
107
|
+
|
|
108
|
+
All reads and writes of ``current_batch`` and ``pending_batch`` MUST
|
|
109
|
+
be performed while holding ``lock``.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
113
|
+
timer: threading.Timer | None = None
|
|
114
|
+
# Paths that triggered events during the current debounce window.
|
|
115
|
+
# Populated by on_any_event (under ``lock``). _fire drains this
|
|
116
|
+
# atomically: either into a new build (no in-flight build) or into
|
|
117
|
+
# ``pending_batch`` (in-flight build running).
|
|
118
|
+
current_batch: set[Path] = field(default_factory=set)
|
|
119
|
+
# Paths that arrived while a build was in flight. Drained by
|
|
120
|
+
# _drain_pending once the in-flight build completes.
|
|
121
|
+
pending_batch: set[Path] = field(default_factory=set)
|
|
122
|
+
# Build serialization: only one build call runs at a time.
|
|
123
|
+
# ``running`` is true between "build started" and "build finished".
|
|
124
|
+
# ``pending_batch`` being non-empty implies a follow-up build is needed.
|
|
125
|
+
running: bool = False
|
|
126
|
+
# refresh_related single-flight: same pattern as running/pending,
|
|
127
|
+
# but for the post-build refresh_related call. Builds skip
|
|
128
|
+
# refresh_related inline (it costs ~70s on a 1100-doc vault); this
|
|
129
|
+
# state coordinates the daemon thread that runs it after each build.
|
|
130
|
+
refresh_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
131
|
+
refresh_running: bool = False
|
|
132
|
+
refresh_pending: bool = False
|
|
133
|
+
stop_event: threading.Event = field(default_factory=threading.Event)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run_watcher(
|
|
137
|
+
vault: Path,
|
|
138
|
+
*,
|
|
139
|
+
quartz_dir: Path | None = None,
|
|
140
|
+
debounce_seconds: float = 1.5,
|
|
141
|
+
keep: int = 3,
|
|
142
|
+
on_build: Callable[[BuildResult], None] | None = None,
|
|
143
|
+
observer_factory: Callable[[], BaseObserver] | None = None,
|
|
144
|
+
refresh_runner: Callable[[], None] | None = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Block until the stop event fires, rebuilding on debounced events.
|
|
147
|
+
|
|
148
|
+
``vault`` is watched recursively. ``quartz_dir`` defaults to
|
|
149
|
+
``vault/.quartz`` and is forwarded to :func:`build_and_swap`.
|
|
150
|
+
``debounce_seconds`` is the quiet period before any event triggers a
|
|
151
|
+
build; rapid bursts (1000 events from a save-format-save IDE cycle)
|
|
152
|
+
collapse to one build.
|
|
153
|
+
|
|
154
|
+
``keep`` is the post-build GC window — see :func:`build_and_swap`.
|
|
155
|
+
|
|
156
|
+
``on_build`` fires once per *successful* build with the
|
|
157
|
+
``BuildResult``. Production wires this to a logging hook so the
|
|
158
|
+
user can tail ``/tmp/brain-build.log`` and see one line per build.
|
|
159
|
+
Tests use it to capture and assert on results.
|
|
160
|
+
|
|
161
|
+
``observer_factory`` defaults to watchdog's stock ``Observer``;
|
|
162
|
+
tests pass a fake to drive events synchronously without touching
|
|
163
|
+
the real FSEvents/inotify subsystem.
|
|
164
|
+
|
|
165
|
+
``refresh_runner`` is forwarded to :class:`_Handler` as its
|
|
166
|
+
background-refresh injection seam. When ``None`` (the default),
|
|
167
|
+
the handler runs the real ``refresh_related`` DB/vault path after
|
|
168
|
+
each build. Tests pass a no-op or spy runner here so they can
|
|
169
|
+
exercise handler logic without touching the real DB or vault. See
|
|
170
|
+
:class:`_Handler` for the full contract.
|
|
171
|
+
|
|
172
|
+
The function returns when ``state.stop_event`` is set — production
|
|
173
|
+
flips it from a SIGINT/SIGTERM handler installed by :func:`main`;
|
|
174
|
+
tests flip it directly.
|
|
175
|
+
"""
|
|
176
|
+
state = _WatcherState()
|
|
177
|
+
handler = _Handler(
|
|
178
|
+
state=state,
|
|
179
|
+
vault=vault,
|
|
180
|
+
quartz_dir=quartz_dir,
|
|
181
|
+
debounce_seconds=debounce_seconds,
|
|
182
|
+
keep=keep,
|
|
183
|
+
on_build=on_build,
|
|
184
|
+
refresh_runner=refresh_runner,
|
|
185
|
+
)
|
|
186
|
+
factory: Callable[[], BaseObserver] = observer_factory or Observer
|
|
187
|
+
observer = factory()
|
|
188
|
+
# brain (2026-05-08): schedule per non-hidden top-level subdir + root
|
|
189
|
+
# non-recursively, instead of `recursive=True` on the vault root.
|
|
190
|
+
# PollingObserver snapshots every file in the watched tree per poll
|
|
191
|
+
# cycle; with `recursive=True` on a vault containing
|
|
192
|
+
# `<vault>/.quartz/node_modules` (~60k+ files) the snapshot starves
|
|
193
|
+
# the polling thread and edits stop being detected. _should_trigger
|
|
194
|
+
# already filters hidden dirs from delivered events, so excluding
|
|
195
|
+
# them from the SCANNED tree is correct + cheap.
|
|
196
|
+
scheduled_names: list[str] = []
|
|
197
|
+
observer.schedule(handler, str(vault), recursive=False)
|
|
198
|
+
for child in vault.iterdir():
|
|
199
|
+
if child.is_dir() and not child.name.startswith("."):
|
|
200
|
+
observer.schedule(handler, str(child), recursive=True)
|
|
201
|
+
scheduled_names.append(child.name)
|
|
202
|
+
logger.info(
|
|
203
|
+
"wiki watcher: scoped polling on top-level dirs: %s",
|
|
204
|
+
", ".join(scheduled_names) or "(none)",
|
|
205
|
+
)
|
|
206
|
+
observer.start()
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
state.stop_event.wait()
|
|
210
|
+
finally:
|
|
211
|
+
# Cancel any pending debounce timer so it doesn't fire after we
|
|
212
|
+
# return — otherwise the test's tmp_path may already be torn
|
|
213
|
+
# down by the time the timer thread runs.
|
|
214
|
+
with state.lock:
|
|
215
|
+
if state.timer is not None:
|
|
216
|
+
state.timer.cancel()
|
|
217
|
+
state.timer = None
|
|
218
|
+
observer.stop()
|
|
219
|
+
observer.join(timeout=5.0)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# Handler + filtering.
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class _Handler(FileSystemEventHandler):
|
|
228
|
+
"""Watchdog handler that gates events into the debounce + build path.
|
|
229
|
+
|
|
230
|
+
Kept thin: filtering lives in :func:`_should_trigger`, debouncing
|
|
231
|
+
lives in :func:`_schedule`, the actual build lives in
|
|
232
|
+
:func:`_run_build`. The handler just wires them together so each
|
|
233
|
+
piece is independently testable.
|
|
234
|
+
|
|
235
|
+
``refresh_runner`` — optional injection seam for the background
|
|
236
|
+
refresh path. When ``None`` (the default), each refresh cycle calls
|
|
237
|
+
:meth:`_run_refresh_related_once`, which loads ``Config`` and calls
|
|
238
|
+
the real ``refresh_related`` against the DB and vault. When a
|
|
239
|
+
runner is provided, the loop calls it instead and skips the
|
|
240
|
+
``Config.load()`` / ``refresh_related()`` path entirely. The
|
|
241
|
+
runner must be thread-safe: it is invoked from the daemon refresh
|
|
242
|
+
thread, not the main thread. It must run synchronously to
|
|
243
|
+
completion before returning; the loop's drain check depends on it.
|
|
244
|
+
Tests use this seam to inject a spy or a no-op so they can
|
|
245
|
+
exercise handler logic without touching the real DB or vault.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
def __init__(
|
|
249
|
+
self,
|
|
250
|
+
*,
|
|
251
|
+
state: _WatcherState,
|
|
252
|
+
vault: Path,
|
|
253
|
+
quartz_dir: Path | None,
|
|
254
|
+
debounce_seconds: float,
|
|
255
|
+
keep: int,
|
|
256
|
+
on_build: Callable[[BuildResult], None] | None,
|
|
257
|
+
refresh_runner: Callable[[], None] | None = None,
|
|
258
|
+
) -> None:
|
|
259
|
+
self._state = state
|
|
260
|
+
self._vault = vault
|
|
261
|
+
self._quartz_dir = quartz_dir
|
|
262
|
+
self._debounce_seconds = debounce_seconds
|
|
263
|
+
self._keep = keep
|
|
264
|
+
self._on_build = on_build
|
|
265
|
+
self._refresh_runner = refresh_runner
|
|
266
|
+
# Part 4 (T6b): read state.json on startup for telemetry only.
|
|
267
|
+
# We use the state purely for logging — it never overrides routing.
|
|
268
|
+
self._read_startup_state()
|
|
269
|
+
|
|
270
|
+
def on_any_event(self, event: FileSystemEvent) -> None:
|
|
271
|
+
# Funnel everything through one classifier so the filter set
|
|
272
|
+
# stays in one place. ``on_any_event`` fires for every type
|
|
273
|
+
# (created/modified/deleted/moved) including directory events,
|
|
274
|
+
# which we drop unconditionally.
|
|
275
|
+
try:
|
|
276
|
+
if not _should_trigger(event, self._vault):
|
|
277
|
+
return
|
|
278
|
+
except Exception:
|
|
279
|
+
# A misbehaving event (bad utf-8 path, etc.) must never
|
|
280
|
+
# kill the Observer thread. Log and move on.
|
|
281
|
+
logger.exception("wiki watcher: filter raised on %r", event)
|
|
282
|
+
return
|
|
283
|
+
# Accumulate the path for batch-size routing detection (T6a/T6b).
|
|
284
|
+
# If a build is already in flight, route to pending_batch so the
|
|
285
|
+
# path is preserved for the follow-up build. Otherwise add to
|
|
286
|
+
# current_batch for the next debounce fire.
|
|
287
|
+
# All reads/writes of current_batch and pending_batch are
|
|
288
|
+
# protected by state.lock.
|
|
289
|
+
#
|
|
290
|
+
# Fix (T6b stale-timer): only schedule a debounce timer when the
|
|
291
|
+
# path goes into current_batch (i.e. no build is in flight).
|
|
292
|
+
# When running=True the path enters pending_batch and _drain_pending
|
|
293
|
+
# will start the follow-up build — no timer is needed or wanted.
|
|
294
|
+
# Scheduling a timer here when running=True would leave a stale timer
|
|
295
|
+
# that fires after _drain_pending clears current_batch; _fire would
|
|
296
|
+
# then see running=False + empty current_batch and call
|
|
297
|
+
# _run_build(frozenset()), routing an empty batch to _do_full_build().
|
|
298
|
+
should_schedule = False
|
|
299
|
+
raw = getattr(event, "src_path", None)
|
|
300
|
+
if raw:
|
|
301
|
+
path = _to_path(raw)
|
|
302
|
+
with self._state.lock:
|
|
303
|
+
if self._state.running:
|
|
304
|
+
self._state.pending_batch.add(path)
|
|
305
|
+
else:
|
|
306
|
+
self._state.current_batch.add(path)
|
|
307
|
+
should_schedule = True
|
|
308
|
+
if should_schedule:
|
|
309
|
+
self._schedule()
|
|
310
|
+
|
|
311
|
+
def _schedule(self) -> None:
|
|
312
|
+
"""Reset the debounce timer to ``debounce_seconds`` from now."""
|
|
313
|
+
with self._state.lock:
|
|
314
|
+
if self._state.timer is not None:
|
|
315
|
+
self._state.timer.cancel()
|
|
316
|
+
timer = threading.Timer(self._debounce_seconds, self._fire)
|
|
317
|
+
timer.daemon = True
|
|
318
|
+
self._state.timer = timer
|
|
319
|
+
timer.start()
|
|
320
|
+
|
|
321
|
+
def _fire(self) -> None:
|
|
322
|
+
"""Debounce expired — request a build (or queue one).
|
|
323
|
+
|
|
324
|
+
Batch-queue semantics (T6b):
|
|
325
|
+
|
|
326
|
+
- If a build is in flight (``running=True``): drain ``current_batch``
|
|
327
|
+
INTO ``pending_batch`` (union), clear ``current_batch``, and return.
|
|
328
|
+
The in-flight build's ``_drain_pending`` will pick up the merged
|
|
329
|
+
pending_batch when it finishes.
|
|
330
|
+
- Otherwise: drain ``current_batch`` → ``batch`` (frozenset), set
|
|
331
|
+
``running=True``, clear ``current_batch``, then run the build
|
|
332
|
+
outside the lock.
|
|
333
|
+
|
|
334
|
+
All state reads/writes are under ``state.lock``,
|
|
335
|
+
which is the single lock covering both batch sets and ``running``.
|
|
336
|
+
"""
|
|
337
|
+
with self._state.lock:
|
|
338
|
+
self._state.timer = None
|
|
339
|
+
if self._state.running:
|
|
340
|
+
# In-flight build: preserve paths in pending_batch.
|
|
341
|
+
self._state.pending_batch.update(self._state.current_batch)
|
|
342
|
+
self._state.current_batch.clear()
|
|
343
|
+
return
|
|
344
|
+
# Fix (T6b stale-timer): defense in depth — if current_batch is
|
|
345
|
+
# empty (e.g. a stale timer fired after _drain_pending already
|
|
346
|
+
# consumed the batch and cleared running), return without starting
|
|
347
|
+
# a build. Without this guard, _run_build(frozenset()) routes an
|
|
348
|
+
# empty batch to _do_full_build(), triggering a spurious full
|
|
349
|
+
# rebuild. With Fix #1 this path should be unreachable in normal
|
|
350
|
+
# operation, but we defend against any future code path that might
|
|
351
|
+
# install a timer without a corresponding current_batch entry.
|
|
352
|
+
if not self._state.current_batch:
|
|
353
|
+
return
|
|
354
|
+
# No in-flight build: drain current_batch and start one.
|
|
355
|
+
batch: frozenset[Path] = frozenset(self._state.current_batch)
|
|
356
|
+
self._state.current_batch.clear()
|
|
357
|
+
self._state.running = True
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
self._run_build(batch)
|
|
361
|
+
finally:
|
|
362
|
+
self._drain_pending()
|
|
363
|
+
|
|
364
|
+
def _drain_pending(self) -> None:
|
|
365
|
+
"""Run a single follow-up build if paths were queued during the last build.
|
|
366
|
+
|
|
367
|
+
Loops because a follow-up build is itself an opportunity for
|
|
368
|
+
more events to land while it runs — but at every iteration we
|
|
369
|
+
only run *one* build, so even a constantly-saving editor can't
|
|
370
|
+
starve us out.
|
|
371
|
+
|
|
372
|
+
Batch-queue semantics (T6b):
|
|
373
|
+
|
|
374
|
+
At each iteration:
|
|
375
|
+
- If ``pending_batch`` is empty → clear ``running`` and return.
|
|
376
|
+
- Otherwise → drain ``pending_batch`` → follow_batch (frozenset),
|
|
377
|
+
keep ``running=True``, then run the follow-up build outside the lock.
|
|
378
|
+
``pending_batch`` is cleared atomically so any events arriving during
|
|
379
|
+
the follow-up build accumulate into a fresh ``pending_batch`` and will
|
|
380
|
+
be drained by the *next* iteration.
|
|
381
|
+
"""
|
|
382
|
+
while True:
|
|
383
|
+
with self._state.lock:
|
|
384
|
+
if not self._state.pending_batch:
|
|
385
|
+
self._state.running = False
|
|
386
|
+
return
|
|
387
|
+
follow_batch: frozenset[Path] = frozenset(self._state.pending_batch)
|
|
388
|
+
self._state.pending_batch.clear()
|
|
389
|
+
# running stays True — we're about to run another build.
|
|
390
|
+
# _run_build already logs via its own try/except; suppressing
|
|
391
|
+
# here just keeps the drain loop alive across transient
|
|
392
|
+
# failures so a queued follow-up rebuild isn't stranded.
|
|
393
|
+
with contextlib.suppress(Exception):
|
|
394
|
+
self._run_build(follow_batch)
|
|
395
|
+
|
|
396
|
+
def _run_build(self, batch: frozenset[Path]) -> None:
|
|
397
|
+
"""Route ``batch`` to the fast path or the full build.
|
|
398
|
+
|
|
399
|
+
Fast path is attempted when:
|
|
400
|
+
- :data:`_FASTPATH_ENABLED` is ``True`` (env gate), AND
|
|
401
|
+
- exactly one Markdown file changed in the debounce window.
|
|
402
|
+
|
|
403
|
+
All other cases (multi-file batch, non-md file, fast-path
|
|
404
|
+
failure) fall through to :meth:`_do_full_build`.
|
|
405
|
+
"""
|
|
406
|
+
if _FASTPATH_ENABLED and _is_single_md_file(batch):
|
|
407
|
+
changed_file = next(iter(batch))
|
|
408
|
+
try:
|
|
409
|
+
self._try_fast_path(changed_file)
|
|
410
|
+
except Exception:
|
|
411
|
+
logger.exception(
|
|
412
|
+
"wiki watcher: fast-path routing failed unexpectedly;"
|
|
413
|
+
" falling back to full build"
|
|
414
|
+
)
|
|
415
|
+
self._do_full_build()
|
|
416
|
+
else:
|
|
417
|
+
self._do_full_build()
|
|
418
|
+
|
|
419
|
+
def _try_fast_path(self, changed_file: Path) -> None:
|
|
420
|
+
"""Attempt a fast partial emit; fall back to full build on any failure.
|
|
421
|
+
|
|
422
|
+
Routing (in order):
|
|
423
|
+
1. Compute slug — ValueError (path outside vault) → full build.
|
|
424
|
+
2. Unsupported-slug pre-check (index / tags/* / */index) → full build.
|
|
425
|
+
3. Classify via manifest fingerprint — NON_TRIVIAL → full build.
|
|
426
|
+
4. Resolve workspace/current → missing → full build.
|
|
427
|
+
5. Call :func:`run_build_partial`; :class:`BrainWikiPartialBuildError`
|
|
428
|
+
→ log warning → full build.
|
|
429
|
+
6. Success → log timing + schedule refresh_related.
|
|
430
|
+
"""
|
|
431
|
+
workspace = (
|
|
432
|
+
self._quartz_dir if self._quartz_dir is not None else self._vault / ".quartz"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# Step 1: slug computation — ValueError means path outside vault.
|
|
436
|
+
try:
|
|
437
|
+
slug = slugify_source_path(changed_file, self._vault)
|
|
438
|
+
except ValueError:
|
|
439
|
+
logger.debug(
|
|
440
|
+
"wiki watcher: fast-path slug error (path outside vault=%s) → full build",
|
|
441
|
+
self._vault,
|
|
442
|
+
)
|
|
443
|
+
self._do_full_build()
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
# Step 2: unsupported-slug pre-check. These slug shapes require a
|
|
447
|
+
# full build; skipping the subprocess avoids subprocess overhead for a
|
|
448
|
+
# guaranteed-full-build case. The Node guard (T4 exit 6) remains as a
|
|
449
|
+
# direct-CLI safety net for callers that bypass this Python layer.
|
|
450
|
+
if slug == "index" or slug.startswith("tags/") or slug.endswith("/index"):
|
|
451
|
+
logger.debug(
|
|
452
|
+
"wiki watcher: unsupported fast-path slug %r → full build", slug
|
|
453
|
+
)
|
|
454
|
+
self._do_full_build()
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
# Step 3: classify the edit via manifest fingerprint.
|
|
458
|
+
fastpath_dir = workspace / ".cache" / "fastpath"
|
|
459
|
+
result = classify_edit(
|
|
460
|
+
fastpath_dir=fastpath_dir,
|
|
461
|
+
source_path=changed_file,
|
|
462
|
+
vault_root=self._vault,
|
|
463
|
+
)
|
|
464
|
+
if result.classification == EditClassification.NON_TRIVIAL:
|
|
465
|
+
logger.debug(
|
|
466
|
+
"wiki watcher: non-trivial edit (reason=%r) → full build",
|
|
467
|
+
result.reason,
|
|
468
|
+
)
|
|
469
|
+
self._do_full_build()
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
# Step 4: resolve the active build dir from the workspace/current symlink.
|
|
473
|
+
current_link = workspace / "current"
|
|
474
|
+
if not current_link.exists():
|
|
475
|
+
logger.debug("wiki watcher: no current build dir → full build")
|
|
476
|
+
self._do_full_build()
|
|
477
|
+
return
|
|
478
|
+
try:
|
|
479
|
+
build_dir = current_link.resolve(strict=True)
|
|
480
|
+
except (OSError, RuntimeError):
|
|
481
|
+
logger.debug("wiki watcher: cannot resolve current build dir → full build")
|
|
482
|
+
self._do_full_build()
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
# Step 5: attempt the partial build.
|
|
486
|
+
try:
|
|
487
|
+
partial_result = run_build_partial(
|
|
488
|
+
slug=slug,
|
|
489
|
+
vault_dir=self._vault,
|
|
490
|
+
build_dir=build_dir,
|
|
491
|
+
workspace_dir=workspace,
|
|
492
|
+
timeout_s=30.0,
|
|
493
|
+
)
|
|
494
|
+
except BrainWikiPartialBuildError as exc:
|
|
495
|
+
logger.warning(
|
|
496
|
+
"wiki: build-partial failed (kind=%s, slug=%s): %s; falling back to full build",
|
|
497
|
+
exc.kind.value,
|
|
498
|
+
exc.slug,
|
|
499
|
+
exc,
|
|
500
|
+
)
|
|
501
|
+
# Increment partial failure counter in state.json before falling back.
|
|
502
|
+
self._increment_partial_failure_count(fastpath_dir=fastpath_dir)
|
|
503
|
+
self._do_full_build()
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
# Step 6: success — update state.json (advisory, non-blocking).
|
|
507
|
+
logger.info(
|
|
508
|
+
"wiki: build-partial slug=%s elapsed=%dms",
|
|
509
|
+
slug,
|
|
510
|
+
partial_result.elapsed_ms,
|
|
511
|
+
)
|
|
512
|
+
self._update_state_after_partial(fastpath_dir=fastpath_dir, slug=slug)
|
|
513
|
+
# Kick off (or queue) the post-build refresh_related run — same
|
|
514
|
+
# single-flight pattern as the full build path.
|
|
515
|
+
self._schedule_refresh_related()
|
|
516
|
+
|
|
517
|
+
def _do_full_build(self) -> None:
|
|
518
|
+
"""Call :func:`build_and_swap`; log + ignore failures."""
|
|
519
|
+
try:
|
|
520
|
+
result = build_and_swap(
|
|
521
|
+
self._vault,
|
|
522
|
+
quartz_dir=self._quartz_dir,
|
|
523
|
+
keep=self._keep,
|
|
524
|
+
# Skip the inline ~70s hybrid-search recompute. The
|
|
525
|
+
# post-build refresh thread below picks it up so the
|
|
526
|
+
# next build emits fresh related-docs JSON without
|
|
527
|
+
# blocking edit-to-UI on this one.
|
|
528
|
+
refresh_related_inline=False,
|
|
529
|
+
)
|
|
530
|
+
except Exception:
|
|
531
|
+
logger.exception("wiki watcher: build failed")
|
|
532
|
+
return
|
|
533
|
+
# Build succeeded — update state.json (advisory, non-blocking).
|
|
534
|
+
workspace = (
|
|
535
|
+
self._quartz_dir if self._quartz_dir is not None else self._vault / ".quartz"
|
|
536
|
+
)
|
|
537
|
+
fastpath_dir = workspace / ".cache" / "fastpath"
|
|
538
|
+
self._update_state_after_full(fastpath_dir=fastpath_dir, build_id=result.build_id)
|
|
539
|
+
# Kick off (or queue) a background refresh_related run.
|
|
540
|
+
# Single-flight: if one's already in flight, mark pending so
|
|
541
|
+
# it re-runs once after it finishes.
|
|
542
|
+
self._schedule_refresh_related()
|
|
543
|
+
if self._on_build is not None:
|
|
544
|
+
try:
|
|
545
|
+
self._on_build(result)
|
|
546
|
+
except Exception:
|
|
547
|
+
logger.exception("wiki watcher: on_build hook raised")
|
|
548
|
+
|
|
549
|
+
# ------------------------------------------------------------------
|
|
550
|
+
# state.json helpers (Parts 3 + 4 of T6b)
|
|
551
|
+
# ------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
def _read_startup_state(self) -> None:
|
|
554
|
+
"""Read state.json on watcher startup — advisory / telemetry only.
|
|
555
|
+
|
|
556
|
+
Never raises. On missing file, IO error, or parse error, logs at
|
|
557
|
+
INFO level and continues. Watcher_pid mismatch (stale state from
|
|
558
|
+
a prior watcher) is also logged at INFO. The result is NEVER used
|
|
559
|
+
as build-routing authority — only for informational logging.
|
|
560
|
+
"""
|
|
561
|
+
workspace = (
|
|
562
|
+
self._quartz_dir if self._quartz_dir is not None else self._vault / ".quartz"
|
|
563
|
+
)
|
|
564
|
+
fastpath_dir = workspace / ".cache" / "fastpath"
|
|
565
|
+
if not fastpath_dir.exists():
|
|
566
|
+
logger.debug(
|
|
567
|
+
"wiki watcher: fastpath_dir %s not found at startup; "
|
|
568
|
+
"state.json will be written on first successful build",
|
|
569
|
+
fastpath_dir,
|
|
570
|
+
)
|
|
571
|
+
return
|
|
572
|
+
from .fastpath_state import read_state # local import — avoids startup overhead
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
state = read_state(fastpath_dir)
|
|
576
|
+
except FastpathStateError as exc:
|
|
577
|
+
logger.info(
|
|
578
|
+
"wiki watcher: startup state.json unreadable (%s); "
|
|
579
|
+
"treating as fresh start",
|
|
580
|
+
exc,
|
|
581
|
+
)
|
|
582
|
+
return
|
|
583
|
+
|
|
584
|
+
if state is None:
|
|
585
|
+
# Missing file or stale PID (read_state returns None for both).
|
|
586
|
+
# Check if there WAS a file with a different PID to emit the
|
|
587
|
+
# stale-PID log line. We do this by reading raw JSON directly.
|
|
588
|
+
try:
|
|
589
|
+
import json as _json
|
|
590
|
+
|
|
591
|
+
raw = (fastpath_dir / "state.json").read_text(encoding="utf-8")
|
|
592
|
+
data = _json.loads(raw)
|
|
593
|
+
prev_pid = data.get("watcher_pid")
|
|
594
|
+
if prev_pid is not None and prev_pid != os.getpid():
|
|
595
|
+
logger.info(
|
|
596
|
+
"wiki watcher: previous watcher pid=%s; this is a fresh watcher",
|
|
597
|
+
prev_pid,
|
|
598
|
+
)
|
|
599
|
+
except (OSError, ValueError):
|
|
600
|
+
pass # File missing or malformed — already handled above.
|
|
601
|
+
return
|
|
602
|
+
|
|
603
|
+
logger.info(
|
|
604
|
+
"wiki watcher: resumed from state.json (last_full_at_ms=%d, "
|
|
605
|
+
"last_partial_at_ms=%d, consecutive_partial_failures=%d)",
|
|
606
|
+
state.last_full_at_ms,
|
|
607
|
+
state.last_partial_at_ms,
|
|
608
|
+
state.consecutive_partial_failures,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
def _update_state_after_full(self, *, fastpath_dir: Path, build_id: str) -> None:
|
|
612
|
+
"""Write state.json after a successful full build.
|
|
613
|
+
|
|
614
|
+
Resets ``consecutive_partial_failures`` to 0, records
|
|
615
|
+
``last_full_at_ms``, and stamps ``watcher_pid``. Non-blocking:
|
|
616
|
+
if the fastpath_dir does not yet exist or any IO error occurs,
|
|
617
|
+
logs a warning and continues — state.json is advisory only.
|
|
618
|
+
"""
|
|
619
|
+
if not fastpath_dir.exists():
|
|
620
|
+
# fastpath dir is created by the Node full-build hook (T2).
|
|
621
|
+
# If it doesn't exist yet, state.json cannot be written.
|
|
622
|
+
logger.debug(
|
|
623
|
+
"wiki watcher: fastpath_dir %s missing; skipping state.json write",
|
|
624
|
+
fastpath_dir,
|
|
625
|
+
)
|
|
626
|
+
return
|
|
627
|
+
from .fastpath_state import read_state # local import avoids circular deps
|
|
628
|
+
|
|
629
|
+
try:
|
|
630
|
+
existing = read_state(fastpath_dir)
|
|
631
|
+
except FastpathStateError:
|
|
632
|
+
existing = None
|
|
633
|
+
|
|
634
|
+
now_ms = int(time.time() * 1000)
|
|
635
|
+
new_state = FastpathState(
|
|
636
|
+
version=1,
|
|
637
|
+
watcher_pid=os.getpid(),
|
|
638
|
+
last_partial_at_ms=existing.last_partial_at_ms if existing is not None else 0,
|
|
639
|
+
last_full_at_ms=now_ms,
|
|
640
|
+
last_partial_slug=existing.last_partial_slug if existing is not None else None,
|
|
641
|
+
consecutive_partial_failures=0,
|
|
642
|
+
)
|
|
643
|
+
try:
|
|
644
|
+
write_state(fastpath_dir, new_state)
|
|
645
|
+
except FastpathStateError:
|
|
646
|
+
logger.warning(
|
|
647
|
+
"wiki watcher: failed to write state.json after full build (build_id=%s)",
|
|
648
|
+
build_id,
|
|
649
|
+
exc_info=True,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
def _update_state_after_partial(self, *, fastpath_dir: Path, slug: str) -> None:
|
|
653
|
+
"""Write state.json after a successful partial build.
|
|
654
|
+
|
|
655
|
+
Increments (resets to 0), records ``last_partial_at_ms``, and
|
|
656
|
+
stores the slug for telemetry. Non-blocking on IO error.
|
|
657
|
+
"""
|
|
658
|
+
if not fastpath_dir.exists():
|
|
659
|
+
logger.debug(
|
|
660
|
+
"wiki watcher: fastpath_dir %s missing; skipping state.json write",
|
|
661
|
+
fastpath_dir,
|
|
662
|
+
)
|
|
663
|
+
return
|
|
664
|
+
from .fastpath_state import read_state # local import avoids circular deps
|
|
665
|
+
|
|
666
|
+
try:
|
|
667
|
+
existing = read_state(fastpath_dir)
|
|
668
|
+
except FastpathStateError:
|
|
669
|
+
existing = None
|
|
670
|
+
|
|
671
|
+
now_ms = int(time.time() * 1000)
|
|
672
|
+
new_state = FastpathState(
|
|
673
|
+
version=1,
|
|
674
|
+
watcher_pid=os.getpid(),
|
|
675
|
+
last_partial_at_ms=now_ms,
|
|
676
|
+
last_full_at_ms=existing.last_full_at_ms if existing is not None else 0,
|
|
677
|
+
last_partial_slug=slug,
|
|
678
|
+
consecutive_partial_failures=0,
|
|
679
|
+
)
|
|
680
|
+
try:
|
|
681
|
+
write_state(fastpath_dir, new_state)
|
|
682
|
+
except FastpathStateError:
|
|
683
|
+
logger.warning(
|
|
684
|
+
"wiki watcher: failed to write state.json after partial build (slug=%s)",
|
|
685
|
+
slug,
|
|
686
|
+
exc_info=True,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
def _increment_partial_failure_count(self, *, fastpath_dir: Path) -> None:
|
|
690
|
+
"""Increment ``consecutive_partial_failures`` in state.json.
|
|
691
|
+
|
|
692
|
+
Called on a partial-build failure so the watcher can force a
|
|
693
|
+
full build after N consecutive partial failures (safety net —
|
|
694
|
+
caller responsibility; this method only increments the counter).
|
|
695
|
+
Non-blocking on IO error.
|
|
696
|
+
"""
|
|
697
|
+
if not fastpath_dir.exists():
|
|
698
|
+
logger.debug(
|
|
699
|
+
"wiki watcher: fastpath_dir %s missing; skipping partial failure counter update",
|
|
700
|
+
fastpath_dir,
|
|
701
|
+
)
|
|
702
|
+
return
|
|
703
|
+
from .fastpath_state import read_state # local import avoids circular deps
|
|
704
|
+
|
|
705
|
+
try:
|
|
706
|
+
existing = read_state(fastpath_dir)
|
|
707
|
+
except FastpathStateError:
|
|
708
|
+
existing = None
|
|
709
|
+
|
|
710
|
+
current_failures = (
|
|
711
|
+
existing.consecutive_partial_failures if existing is not None else 0
|
|
712
|
+
)
|
|
713
|
+
new_state = FastpathState(
|
|
714
|
+
version=1,
|
|
715
|
+
watcher_pid=os.getpid(),
|
|
716
|
+
last_partial_at_ms=existing.last_partial_at_ms if existing is not None else 0,
|
|
717
|
+
last_full_at_ms=existing.last_full_at_ms if existing is not None else 0,
|
|
718
|
+
last_partial_slug=existing.last_partial_slug if existing is not None else None,
|
|
719
|
+
consecutive_partial_failures=current_failures + 1,
|
|
720
|
+
)
|
|
721
|
+
try:
|
|
722
|
+
write_state(fastpath_dir, new_state)
|
|
723
|
+
except FastpathStateError:
|
|
724
|
+
logger.warning(
|
|
725
|
+
"wiki watcher: failed to write state.json for partial failure counter",
|
|
726
|
+
exc_info=True,
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
def _schedule_refresh_related(self) -> None:
|
|
730
|
+
"""Spawn a daemon thread to run refresh_related, single-flight.
|
|
731
|
+
|
|
732
|
+
If a refresh is already running, just set ``refresh_pending`` —
|
|
733
|
+
the running thread's drain loop will see the flag when it
|
|
734
|
+
finishes and run exactly one more refresh.
|
|
735
|
+
"""
|
|
736
|
+
with self._state.refresh_lock:
|
|
737
|
+
if self._state.refresh_running:
|
|
738
|
+
self._state.refresh_pending = True
|
|
739
|
+
return
|
|
740
|
+
self._state.refresh_running = True
|
|
741
|
+
t = threading.Thread(
|
|
742
|
+
target=self._refresh_related_loop,
|
|
743
|
+
name="brain-wiki-refresh-related",
|
|
744
|
+
daemon=True,
|
|
745
|
+
)
|
|
746
|
+
t.start()
|
|
747
|
+
|
|
748
|
+
def _refresh_related_loop(self) -> None:
|
|
749
|
+
"""Run ``refresh_related`` until no more pending flag is set.
|
|
750
|
+
|
|
751
|
+
Mirrors ``_drain_pending`` for builds: after each refresh the
|
|
752
|
+
thread checks if another build landed during its run, and if
|
|
753
|
+
so runs exactly one more refresh. This keeps the related-docs
|
|
754
|
+
JSON converging on the latest DB state without spawning
|
|
755
|
+
unbounded concurrent refreshes.
|
|
756
|
+
|
|
757
|
+
When ``self._refresh_runner`` is set (injected via
|
|
758
|
+
:class:`_Handler.__init__`), that runner is invoked instead
|
|
759
|
+
of :meth:`_run_refresh_related_once` — no ``Config.load()``
|
|
760
|
+
or ``refresh_related`` is called. Production-default behavior
|
|
761
|
+
is ``None``; tests inject a runner here.
|
|
762
|
+
"""
|
|
763
|
+
while True:
|
|
764
|
+
try:
|
|
765
|
+
if self._refresh_runner is not None:
|
|
766
|
+
self._refresh_runner()
|
|
767
|
+
else:
|
|
768
|
+
self._run_refresh_related_once()
|
|
769
|
+
except Exception:
|
|
770
|
+
# refresh_related catches its own DB/IO errors and returns
|
|
771
|
+
# a summary; getting here means an unexpected programmer
|
|
772
|
+
# error. Log and let the drain decide whether to retry.
|
|
773
|
+
logger.exception("wiki watcher: refresh_related raised")
|
|
774
|
+
with self._state.refresh_lock:
|
|
775
|
+
if not self._state.refresh_pending:
|
|
776
|
+
self._state.refresh_running = False
|
|
777
|
+
return
|
|
778
|
+
self._state.refresh_pending = False
|
|
779
|
+
|
|
780
|
+
def _run_refresh_related_once(self) -> None:
|
|
781
|
+
"""Best-effort single invocation of ``refresh_related``."""
|
|
782
|
+
# Imports inside the function so a Config-load failure (no
|
|
783
|
+
# DATABASE_URL on PATH for whatever reason) doesn't kill the
|
|
784
|
+
# whole watcher process at startup.
|
|
785
|
+
try:
|
|
786
|
+
from ..config import Config, ConfigError
|
|
787
|
+
from .build_related import refresh_related
|
|
788
|
+
except ImportError:
|
|
789
|
+
logger.exception("wiki watcher: refresh_related import failed")
|
|
790
|
+
return
|
|
791
|
+
try:
|
|
792
|
+
cfg = Config.load()
|
|
793
|
+
except ConfigError as exc:
|
|
794
|
+
logger.warning(
|
|
795
|
+
"wiki watcher: refresh_related skipped (Config.load failed: %s)",
|
|
796
|
+
exc,
|
|
797
|
+
)
|
|
798
|
+
return
|
|
799
|
+
target_vault = self._vault.expanduser().resolve()
|
|
800
|
+
cfg_for_build = (
|
|
801
|
+
cfg
|
|
802
|
+
if cfg.vault_path == target_vault
|
|
803
|
+
else _replace_vault_path(cfg, target_vault)
|
|
804
|
+
)
|
|
805
|
+
refresh_related(cfg_for_build)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _should_trigger(event: FileSystemEvent, vault: Path) -> bool:
|
|
809
|
+
"""Return True iff this event should reset the debounce timer.
|
|
810
|
+
|
|
811
|
+
Filtering rules:
|
|
812
|
+
|
|
813
|
+
- Directory events skipped — Quartz cares about file content, not
|
|
814
|
+
directory mtime.
|
|
815
|
+
- Paths under ``<vault>/.git`` or ``<vault>/.quartz`` skipped —
|
|
816
|
+
``.git`` is irrelevant to the rendered site, and ``.quartz``
|
|
817
|
+
contains our *own* build output (a loop trigger).
|
|
818
|
+
- Editor-artifact paths skipped — Emacs lock files (``.#foo``)
|
|
819
|
+
and tilde-suffix backups (``foo~``) shouldn't kick a 40s build.
|
|
820
|
+
- Everything else (any extension, any depth) accepted: a vault
|
|
821
|
+
that includes images, custom CSS, or non-``.md`` source files
|
|
822
|
+
should still rebuild on those changes.
|
|
823
|
+
"""
|
|
824
|
+
if event.is_directory:
|
|
825
|
+
return False
|
|
826
|
+
raw = getattr(event, "src_path", None)
|
|
827
|
+
if not raw:
|
|
828
|
+
return False
|
|
829
|
+
path = _to_path(raw)
|
|
830
|
+
if not _within(path, vault):
|
|
831
|
+
# Watchdog can occasionally surface paths outside the watched
|
|
832
|
+
# tree (e.g. when a file is moved out); we don't rebuild for
|
|
833
|
+
# those.
|
|
834
|
+
return False
|
|
835
|
+
name = path.name
|
|
836
|
+
if name.startswith(".#") or name.endswith("~"):
|
|
837
|
+
return False
|
|
838
|
+
try:
|
|
839
|
+
relative = path.resolve().relative_to(vault.resolve())
|
|
840
|
+
except (OSError, ValueError):
|
|
841
|
+
try:
|
|
842
|
+
relative = path.relative_to(vault)
|
|
843
|
+
except ValueError:
|
|
844
|
+
return False
|
|
845
|
+
parts = relative.parts
|
|
846
|
+
if not parts:
|
|
847
|
+
return False
|
|
848
|
+
if parts[0] in _IGNORED_TOP_DIRS:
|
|
849
|
+
return False
|
|
850
|
+
# Exclude our own writes to <vault>/static/related/<slug>.json — the
|
|
851
|
+
# post-build refresh thread writes those, and a fresh build is the
|
|
852
|
+
# ONLY thing that would publish them. If we treat those writes as
|
|
853
|
+
# rebuild triggers we get an infinite loop:
|
|
854
|
+
# build → spawn refresh thread → write JSON → fsevent → another
|
|
855
|
+
# build → spawn another refresh → another JSON write → …
|
|
856
|
+
# See `_schedule_refresh_related` in this module.
|
|
857
|
+
return not (len(parts) >= 2 and parts[0] == "static" and parts[1] == "related")
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _to_path(raw: str | bytes) -> Path:
|
|
861
|
+
"""Decode a watchdog event path (``bytes | str``) to ``Path``.
|
|
862
|
+
|
|
863
|
+
Mirrors ``brain.vault.watch._to_path`` — Linux inotify can surface
|
|
864
|
+
raw bytes; macOS FSEvents always sends str; we normalize eagerly.
|
|
865
|
+
"""
|
|
866
|
+
if isinstance(raw, bytes):
|
|
867
|
+
return Path(os.fsdecode(raw))
|
|
868
|
+
return Path(raw)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _within(path: Path, vault: Path) -> bool:
|
|
872
|
+
"""Cheap lexical check: is ``path`` under ``vault``?
|
|
873
|
+
|
|
874
|
+
Used as a pre-filter before the more expensive ``resolve()`` call
|
|
875
|
+
in :func:`_should_trigger` — handles the common case where the
|
|
876
|
+
event path is already absolute and the vault path is canonical.
|
|
877
|
+
"""
|
|
878
|
+
try:
|
|
879
|
+
path.relative_to(vault)
|
|
880
|
+
return True
|
|
881
|
+
except ValueError:
|
|
882
|
+
return False
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
# ---------------------------------------------------------------------------
|
|
886
|
+
# CLI entry point.
|
|
887
|
+
# ---------------------------------------------------------------------------
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def main(argv: list[str] | None = None) -> None:
|
|
891
|
+
"""Console entry — ``python -m brain.wiki.build_watcher --vault ...``.
|
|
892
|
+
|
|
893
|
+
Parses CLI flags, optionally runs an initial synchronous build (for
|
|
894
|
+
cold starts where ``current/`` doesn't exist yet), then enters the
|
|
895
|
+
watcher loop and blocks until SIGINT/SIGTERM. ``brain-up`` spawns
|
|
896
|
+
this as a background process; ``brain-down`` kills it via PID file.
|
|
897
|
+
"""
|
|
898
|
+
parser = argparse.ArgumentParser(
|
|
899
|
+
prog="brain.wiki.build_watcher",
|
|
900
|
+
description="Watch a vault and trigger atomic Quartz rebuilds.",
|
|
901
|
+
)
|
|
902
|
+
parser.add_argument("--vault", required=True, type=Path, help="Vault root path.")
|
|
903
|
+
parser.add_argument(
|
|
904
|
+
"--quartz-dir",
|
|
905
|
+
type=Path,
|
|
906
|
+
default=None,
|
|
907
|
+
help="Quartz workspace dir (default: <vault>/.quartz).",
|
|
908
|
+
)
|
|
909
|
+
parser.add_argument(
|
|
910
|
+
"--debounce-seconds",
|
|
911
|
+
type=float,
|
|
912
|
+
default=1.5,
|
|
913
|
+
help="Quiet period before a rebuild (default: 1.5s).",
|
|
914
|
+
)
|
|
915
|
+
parser.add_argument(
|
|
916
|
+
"--keep",
|
|
917
|
+
type=int,
|
|
918
|
+
default=3,
|
|
919
|
+
help="How many old build dirs to retain after each swap (default: 3).",
|
|
920
|
+
)
|
|
921
|
+
parser.add_argument(
|
|
922
|
+
"--initial-build",
|
|
923
|
+
action="store_true",
|
|
924
|
+
help="Run one synchronous build before starting the watcher.",
|
|
925
|
+
)
|
|
926
|
+
args = parser.parse_args(argv)
|
|
927
|
+
|
|
928
|
+
logging.basicConfig(
|
|
929
|
+
level=logging.INFO,
|
|
930
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
vault: Path = args.vault.expanduser().resolve()
|
|
934
|
+
quartz_dir: Path | None = (
|
|
935
|
+
args.quartz_dir.expanduser().resolve() if args.quartz_dir is not None else None
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
if args.initial_build:
|
|
939
|
+
_run_initial_build(vault, quartz_dir=quartz_dir, keep=args.keep)
|
|
940
|
+
|
|
941
|
+
logger.info("wiki watcher: started for vault=%s", vault)
|
|
942
|
+
run_watcher(
|
|
943
|
+
vault,
|
|
944
|
+
quartz_dir=quartz_dir,
|
|
945
|
+
debounce_seconds=args.debounce_seconds,
|
|
946
|
+
keep=args.keep,
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _run_initial_build(
|
|
951
|
+
vault: Path, *, quartz_dir: Path | None, keep: int
|
|
952
|
+
) -> BuildResult | None:
|
|
953
|
+
"""Run one synchronous ``build_and_swap`` before the watcher starts.
|
|
954
|
+
|
|
955
|
+
Surfaced as a separate function so :func:`main` stays small and
|
|
956
|
+
tests can drive the cold-start path directly without spinning up
|
|
957
|
+
the whole watcher. Returns the build result on success, ``None``
|
|
958
|
+
on failure (failures are logged; the watcher still starts so the
|
|
959
|
+
next vault edit gets another shot).
|
|
960
|
+
"""
|
|
961
|
+
try:
|
|
962
|
+
result = build_and_swap(vault, quartz_dir=quartz_dir, keep=keep)
|
|
963
|
+
except Exception:
|
|
964
|
+
logger.exception("wiki watcher: initial build failed")
|
|
965
|
+
return None
|
|
966
|
+
logger.info(
|
|
967
|
+
"wiki watcher: initial build %s done in %.2fs",
|
|
968
|
+
result.build_id,
|
|
969
|
+
result.elapsed_seconds,
|
|
970
|
+
)
|
|
971
|
+
return result
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
if __name__ == "__main__": # pragma: no cover — exercised via subprocess in CLI
|
|
975
|
+
main(sys.argv[1:])
|