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/wiki/build_swap.py
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
"""Atomic blue/green Quartz build + symlink swap.
|
|
2
|
+
|
|
3
|
+
Each call to :func:`build_and_swap` runs
|
|
4
|
+
``node <quartz_dir>/quartz/bootstrap-cli.mjs build`` into a fresh
|
|
5
|
+
``<quartz_dir>/builds/<ts>-<rand>/`` directory and then atomically retargets
|
|
6
|
+
the ``<quartz_dir>/current`` symlink at the new build via a temp-symlink +
|
|
7
|
+
``rename(2)`` dance. Caddy serves ``current/`` directly, so readers only
|
|
8
|
+
ever see a fully-written tree — even mid-build, the previous build remains
|
|
9
|
+
the one on the wire.
|
|
10
|
+
|
|
11
|
+
The local Quartz workspace (``<quartz_dir>/quartz/bootstrap-cli.mjs``) is
|
|
12
|
+
invoked directly via ``node`` rather than ``npx quartz`` to eliminate the
|
|
13
|
+
~100 s of npm-exec/package-resolution overhead that ``npx`` can introduce
|
|
14
|
+
before Quartz's own build timer starts. If ``bootstrap-cli.mjs`` is absent
|
|
15
|
+
or ``node`` is not on PATH, :func:`build_and_swap` hard-fails with a clear
|
|
16
|
+
repair-path message — there is **no automatic npx fallback**.
|
|
17
|
+
|
|
18
|
+
Quartz 4.5.x (pinned in ``package.json``) reliably supports ``--output``,
|
|
19
|
+
so ``_run_build`` uses that flag directly without any version probing.
|
|
20
|
+
|
|
21
|
+
After a successful swap, old build directories are garbage-collected: the
|
|
22
|
+
N most recent (by mtime) survive plus whichever directory ``current`` now
|
|
23
|
+
points at, even if it would otherwise be evicted. This guarantees the
|
|
24
|
+
active build is never deleted out from under Caddy.
|
|
25
|
+
|
|
26
|
+
This module also exposes a one-shot CLI entry point — ``python -m
|
|
27
|
+
brain.wiki.build_swap --vault PATH [--quartz-dir PATH] [--keep N]`` —
|
|
28
|
+
used by ``bin/brain-up`` (cold-start build) and ``bin/brain-rebuild``
|
|
29
|
+
(forced rebuild) so those scripts never have to import Python directly.
|
|
30
|
+
Exits 0 on success, 1 on a wrapped :class:`BrainWikiError`, and 2 (via
|
|
31
|
+
argparse) on bad arguments.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import dataclasses
|
|
37
|
+
import logging
|
|
38
|
+
import secrets
|
|
39
|
+
import shutil
|
|
40
|
+
import subprocess
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
import warnings
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from datetime import datetime
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import TYPE_CHECKING, Literal
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING: # pragma: no cover — type-only import to avoid runtime cycle
|
|
50
|
+
from ..config import Config
|
|
51
|
+
|
|
52
|
+
from .errors import BrainWikiBuildError, BrainWikiError
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class BuildResult:
|
|
59
|
+
"""Summary of a single :func:`build_and_swap` invocation.
|
|
60
|
+
|
|
61
|
+
``build_dir`` is the absolute path to the new build's tree; ``build_id``
|
|
62
|
+
is its basename and the value written into ``build_dir/.build-id`` for
|
|
63
|
+
the reload-poller to read. ``elapsed_seconds`` covers the whole call
|
|
64
|
+
(build + swap + GC). ``pruned`` lists every build directory deleted by
|
|
65
|
+
garbage collection — empty on early runs, non-empty once we exceed
|
|
66
|
+
``keep``. ``method`` is always ``"output-flag"`` (Quartz 4.5.x is
|
|
67
|
+
pinned and reliably supports ``--output``); it is preserved on the
|
|
68
|
+
result for observability and test assertions.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
build_dir: Path
|
|
72
|
+
build_id: str
|
|
73
|
+
elapsed_seconds: float
|
|
74
|
+
pruned: list[Path]
|
|
75
|
+
method: Literal["output-flag"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_and_swap(
|
|
79
|
+
vault: Path,
|
|
80
|
+
*,
|
|
81
|
+
quartz_dir: Path | None = None,
|
|
82
|
+
keep: int = 3,
|
|
83
|
+
node_path: str | None = None,
|
|
84
|
+
timeout_seconds: float = 600.0,
|
|
85
|
+
env: dict[str, str] | None = None,
|
|
86
|
+
refresh_related_inline: bool = True,
|
|
87
|
+
npx_path: str | None = None, # Deprecated and ignored. Kept for API compatibility.
|
|
88
|
+
) -> BuildResult:
|
|
89
|
+
"""Build the vault into a fresh dir and atomically retarget ``current``.
|
|
90
|
+
|
|
91
|
+
``vault`` is the absolute path to the markdown root; Quartz is invoked
|
|
92
|
+
with ``--directory <vault>``. ``quartz_dir`` defaults to
|
|
93
|
+
``<vault>/.quartz`` — that's where ``package.json`` and
|
|
94
|
+
``quartz.config.ts`` must live (the dir created by ``npx quartz create``).
|
|
95
|
+
|
|
96
|
+
``keep`` controls how many old build directories survive the post-swap
|
|
97
|
+
garbage-collect step (separately from the active ``current`` target,
|
|
98
|
+
which is *always* preserved — see :func:`_garbage_collect`).
|
|
99
|
+
|
|
100
|
+
``node_path`` is the Node.js binary to invoke for the build subprocess.
|
|
101
|
+
When ``None`` (the default), it is resolved via
|
|
102
|
+
:func:`shutil.which`\\ ``("node")`` at call time. If ``node`` is not on
|
|
103
|
+
PATH a :class:`BrainWikiBuildError` is raised with a clear install hint.
|
|
104
|
+
Tests may pass an explicit path to a stub node script.
|
|
105
|
+
|
|
106
|
+
``npx_path`` is **deprecated and ignored** — accepted for backwards API
|
|
107
|
+
compatibility only. Passing any non-``None`` value emits a
|
|
108
|
+
:class:`DeprecationWarning`. The build subprocess now invokes ``node``
|
|
109
|
+
directly via the pinned local Quartz workspace
|
|
110
|
+
(``<quartz_dir>/quartz/bootstrap-cli.mjs``) to eliminate ~100 s of
|
|
111
|
+
npm-exec overhead from the watcher hot path.
|
|
112
|
+
|
|
113
|
+
``timeout_seconds`` is a hard wall-clock ceiling on the build
|
|
114
|
+
subprocess. The default (10 min) accommodates large vaults with
|
|
115
|
+
expensive Quartz transformers without ever leaving a runaway build
|
|
116
|
+
holding the process forever.
|
|
117
|
+
|
|
118
|
+
``env`` is an optional extra-env dict merged into ``os.environ`` for
|
|
119
|
+
the build subprocess — used by tests to opt the stub script into
|
|
120
|
+
different output modes via env vars; production passes ``None``.
|
|
121
|
+
|
|
122
|
+
Raises :class:`BrainWikiError` if the workspace is missing or
|
|
123
|
+
``bootstrap-cli.mjs`` is absent (with a repair-path hint); raises
|
|
124
|
+
:class:`BrainWikiBuildError` if ``node`` is not on PATH or if the build
|
|
125
|
+
subprocess fails. Partial output (a half-written ``build_dir`` left
|
|
126
|
+
behind by a failing build) is cleaned up before the exception propagates
|
|
127
|
+
so retries land on a clean slate.
|
|
128
|
+
"""
|
|
129
|
+
if npx_path is not None:
|
|
130
|
+
warnings.warn(
|
|
131
|
+
"npx_path is deprecated and ignored; build now invokes node directly",
|
|
132
|
+
DeprecationWarning,
|
|
133
|
+
stacklevel=2,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
started = time.monotonic()
|
|
137
|
+
workspace = quartz_dir if quartz_dir is not None else vault / ".quartz"
|
|
138
|
+
_check_workspace(workspace) # Also asserts bootstrap-cli.mjs is present.
|
|
139
|
+
|
|
140
|
+
# Resolve the node binary before any other work — hard-fail loud rather
|
|
141
|
+
# than falling back to npx (which adds ~100 s of variance per Codex
|
|
142
|
+
# measurement).
|
|
143
|
+
resolved_node: str
|
|
144
|
+
if node_path is not None:
|
|
145
|
+
resolved_node = node_path
|
|
146
|
+
else:
|
|
147
|
+
found = shutil.which("node")
|
|
148
|
+
if found is None:
|
|
149
|
+
raise BrainWikiBuildError(
|
|
150
|
+
"node binary not found on PATH; install Node.js"
|
|
151
|
+
" (Homebrew: `brew install node`;"
|
|
152
|
+
" Linux: nodejs.org or your distro's package manager)"
|
|
153
|
+
)
|
|
154
|
+
resolved_node = found
|
|
155
|
+
|
|
156
|
+
_refresh_pre_build_adornments(vault, refresh_related_inline=refresh_related_inline)
|
|
157
|
+
|
|
158
|
+
# Quartz 4.5.x is pinned in package.json and reliably supports --output.
|
|
159
|
+
# No version probe needed — always use the output-flag path.
|
|
160
|
+
method: Literal["output-flag"] = "output-flag"
|
|
161
|
+
|
|
162
|
+
builds_root = workspace / "builds"
|
|
163
|
+
builds_root.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
|
|
165
|
+
build_id = _generate_build_id()
|
|
166
|
+
build_dir = builds_root / build_id
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
_run_build(
|
|
170
|
+
node_path=resolved_node,
|
|
171
|
+
workspace=workspace,
|
|
172
|
+
vault=vault,
|
|
173
|
+
build_dir=build_dir,
|
|
174
|
+
build_id=build_id,
|
|
175
|
+
timeout_seconds=timeout_seconds,
|
|
176
|
+
env=env,
|
|
177
|
+
)
|
|
178
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc:
|
|
179
|
+
# Clean up any partial output before re-raising so the next attempt
|
|
180
|
+
# doesn't trip over a half-written tree.
|
|
181
|
+
if build_dir.exists():
|
|
182
|
+
shutil.rmtree(build_dir, ignore_errors=True)
|
|
183
|
+
# Invalidate fastpath artifacts written by the Quartz subprocess before
|
|
184
|
+
# it failed. writeFastpathArtifacts() in build.ts fires after
|
|
185
|
+
# emitContent() and is wrapped in try/catch, so it CAN succeed even
|
|
186
|
+
# when the overall build exits non-zero. If those artifacts are left on
|
|
187
|
+
# disk, classify_edit will read fingerprints from the failed (never-
|
|
188
|
+
# swapped) build and may route the next edit to build-partial, which
|
|
189
|
+
# would emit HTML based on a stale/inconsistent contentmap. Deleting
|
|
190
|
+
# manifest.json forces classify_edit to return NON_TRIVIAL, so the next
|
|
191
|
+
# watcher-triggered build starts fresh and writes consistent artifacts.
|
|
192
|
+
# Best-effort: _invalidate_fastpath_manifest never raises.
|
|
193
|
+
_invalidate_fastpath_manifest(workspace)
|
|
194
|
+
if isinstance(exc, subprocess.CalledProcessError):
|
|
195
|
+
raise BrainWikiBuildError(
|
|
196
|
+
f"quartz build failed (exit {exc.returncode}) for vault {vault}"
|
|
197
|
+
) from exc
|
|
198
|
+
if isinstance(exc, subprocess.TimeoutExpired):
|
|
199
|
+
raise BrainWikiBuildError(
|
|
200
|
+
f"quartz build exceeded {timeout_seconds}s for vault {vault}"
|
|
201
|
+
) from exc
|
|
202
|
+
raise BrainWikiBuildError(
|
|
203
|
+
f"quartz build raised {type(exc).__name__} for vault {vault}: {exc}"
|
|
204
|
+
) from exc
|
|
205
|
+
|
|
206
|
+
# The two steps below — writing .build-id into the new build dir and
|
|
207
|
+
# atomically retargeting the ``current`` symlink — are both "post-build,
|
|
208
|
+
# pre-committed" operations: the subprocess already wrote fastpath
|
|
209
|
+
# artifacts (manifest.json + contentmap.json) tagged with ``build_id``
|
|
210
|
+
# to fastpath_dir. If EITHER step raises, ``current`` still points at
|
|
211
|
+
# the previous build while the manifest carries fingerprints from the new
|
|
212
|
+
# (uncommitted) one. We must invalidate the stale manifest on ANY
|
|
213
|
+
# OSError here — not only on the swap itself — so the classifier routes
|
|
214
|
+
# the next edit to a full build rather than a partial one on stale data.
|
|
215
|
+
try:
|
|
216
|
+
# Mark the build with its id. The reload-poller reads this file via
|
|
217
|
+
# /.build-id (Caddy serves the build root directly).
|
|
218
|
+
(build_dir / ".build-id").write_text(f"{build_id}\n", encoding="utf-8")
|
|
219
|
+
_atomic_swap(workspace, build_id)
|
|
220
|
+
except OSError as exc:
|
|
221
|
+
# Deleting manifest.json forces classify_edit to return NON_TRIVIAL on
|
|
222
|
+
# the next edit, routing to a full build that writes a fresh + consistent
|
|
223
|
+
# manifest once the commit succeeds.
|
|
224
|
+
_invalidate_fastpath_manifest(workspace)
|
|
225
|
+
raise BrainWikiBuildError(
|
|
226
|
+
f"quartz build swap failed for vault {vault}: {exc}"
|
|
227
|
+
) from exc
|
|
228
|
+
|
|
229
|
+
pruned = _garbage_collect(workspace, keep=keep)
|
|
230
|
+
|
|
231
|
+
elapsed = time.monotonic() - started
|
|
232
|
+
return BuildResult(
|
|
233
|
+
build_dir=build_dir,
|
|
234
|
+
build_id=build_id,
|
|
235
|
+
elapsed_seconds=elapsed,
|
|
236
|
+
pruned=pruned,
|
|
237
|
+
method=method,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Internal helpers.
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _invalidate_fastpath_manifest(workspace: Path) -> None:
|
|
247
|
+
"""Delete ``fastpath_dir/manifest.json`` after a failed symlink swap.
|
|
248
|
+
|
|
249
|
+
When :func:`_run_build` succeeds, the Quartz subprocess writes
|
|
250
|
+
``<workspace>/.cache/fastpath/manifest.json`` (and ``contentmap.json``)
|
|
251
|
+
tagged with the new ``build_id``. If the subsequent :func:`_atomic_swap`
|
|
252
|
+
then fails, ``current`` still points at the *previous* build while
|
|
253
|
+
``manifest.json`` carries fingerprints from the *new* (unswapped) build.
|
|
254
|
+
|
|
255
|
+
Leaving the stale manifest on disk is dangerous: :func:`classify_edit`
|
|
256
|
+
would read the new fingerprints and might route the next edit as
|
|
257
|
+
``TRIVIAL`` — launching ``build-partial`` against a live build dir whose
|
|
258
|
+
HTML is from an earlier state. Deleting the manifest file forces
|
|
259
|
+
``classify_edit`` to return ``NON_TRIVIAL`` (``ManifestError`` → full
|
|
260
|
+
build), so the next watcher-driven build writes a fresh, consistent
|
|
261
|
+
manifest once the swap succeeds.
|
|
262
|
+
|
|
263
|
+
This function is **best-effort**: it never raises. A missing file is
|
|
264
|
+
silently ignored; any other ``OSError`` is logged at WARNING level so the
|
|
265
|
+
operator knows but the caller's exception path is unaffected.
|
|
266
|
+
"""
|
|
267
|
+
manifest_path = workspace / ".cache" / "fastpath" / "manifest.json"
|
|
268
|
+
try:
|
|
269
|
+
manifest_path.unlink()
|
|
270
|
+
logger.debug(
|
|
271
|
+
"wiki build: invalidated stale fastpath manifest after swap failure (%s)",
|
|
272
|
+
manifest_path,
|
|
273
|
+
)
|
|
274
|
+
except FileNotFoundError:
|
|
275
|
+
pass # already gone — nothing to do
|
|
276
|
+
except OSError as exc:
|
|
277
|
+
logger.warning(
|
|
278
|
+
"wiki build: could not invalidate stale fastpath manifest %s: %s",
|
|
279
|
+
manifest_path,
|
|
280
|
+
exc,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _check_workspace(workspace: Path) -> None:
|
|
285
|
+
"""Validate that ``workspace`` looks like a Quartz workspace.
|
|
286
|
+
|
|
287
|
+
Performs two checks:
|
|
288
|
+
|
|
289
|
+
1. ``quartz.config.ts`` — written by ``npx quartz create``, required by
|
|
290
|
+
our overlay step. Guards against pointing the builder at an empty dir.
|
|
291
|
+
|
|
292
|
+
2. ``quartz/bootstrap-cli.mjs`` — the Node.js entry point that
|
|
293
|
+
:func:`_run_build` invokes directly (instead of ``npx quartz``). If
|
|
294
|
+
the file is absent the workspace is incomplete — the repair path is
|
|
295
|
+
``cd <workspace> && npm install``.
|
|
296
|
+
|
|
297
|
+
We do *not* validate ``package.json`` or ``node_modules`` beyond the
|
|
298
|
+
bootstrap-cli check; the caller (brain-up cold start) has its own
|
|
299
|
+
friendlier preflight for those.
|
|
300
|
+
"""
|
|
301
|
+
if not workspace.is_dir():
|
|
302
|
+
raise BrainWikiError(f"quartz workspace not found at {workspace}")
|
|
303
|
+
if not (workspace / "quartz.config.ts").is_file():
|
|
304
|
+
raise BrainWikiError(
|
|
305
|
+
f"quartz workspace at {workspace} is missing quartz.config.ts"
|
|
306
|
+
)
|
|
307
|
+
bootstrap = workspace / "quartz" / "bootstrap-cli.mjs"
|
|
308
|
+
if not bootstrap.is_file():
|
|
309
|
+
raise BrainWikiBuildError(
|
|
310
|
+
f"Quartz bootstrap CLI not found at {bootstrap};"
|
|
311
|
+
f' run `cd "{workspace}" && npm install` to repair the workspace'
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _generate_build_id() -> str:
|
|
316
|
+
"""Return a fresh ``YYYYMMDD-HHMMSS-<6 hex>`` id.
|
|
317
|
+
|
|
318
|
+
Timestamp + 24 bits of randomness keeps ids monotonic-by-time (so
|
|
319
|
+
sort-by-name in ``ls`` lines up with sort-by-mtime) but still
|
|
320
|
+
collision-free if two builds race within the same second.
|
|
321
|
+
"""
|
|
322
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
323
|
+
return f"{stamp}-{secrets.token_hex(3)}"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _run_build(
|
|
327
|
+
*,
|
|
328
|
+
node_path: str,
|
|
329
|
+
workspace: Path,
|
|
330
|
+
vault: Path,
|
|
331
|
+
build_dir: Path,
|
|
332
|
+
build_id: str,
|
|
333
|
+
timeout_seconds: float,
|
|
334
|
+
env: dict[str, str] | None,
|
|
335
|
+
) -> None:
|
|
336
|
+
"""Invoke ``node <workspace>/quartz/bootstrap-cli.mjs build --output <build_dir>``.
|
|
337
|
+
|
|
338
|
+
Implementation is split off :func:`build_and_swap` so the parent can
|
|
339
|
+
own the cleanup-on-error semantics in one place — this function just
|
|
340
|
+
runs the subprocess and lets every exception type bubble up untouched.
|
|
341
|
+
|
|
342
|
+
``node_path`` is the resolved Node.js binary (pre-validated by the
|
|
343
|
+
caller); ``workspace / "quartz" / "bootstrap-cli.mjs"`` is the Quartz
|
|
344
|
+
entry point (pre-validated by :func:`_check_workspace`).
|
|
345
|
+
|
|
346
|
+
``build_id`` is injected into the subprocess environment as
|
|
347
|
+
``QUARTZ_PARENT_BUILD_ID`` so the Quartz overlay can write the
|
|
348
|
+
fastpath manifest (``manifest.json`` + ``contentmap.json``) tagged to
|
|
349
|
+
this build. Without it the overlay logs "skipping fastpath artifact
|
|
350
|
+
write" and every subsequent :func:`build_partial` call hits an
|
|
351
|
+
envelope mismatch and falls back to a full rebuild.
|
|
352
|
+
|
|
353
|
+
Quartz 4.5.x is pinned in ``package.json`` and reliably supports
|
|
354
|
+
``--output``, so no method probe or fallback is needed.
|
|
355
|
+
"""
|
|
356
|
+
import os # local import — only needed inside the build path
|
|
357
|
+
|
|
358
|
+
# Always build a merged env so QUARTZ_PARENT_BUILD_ID reaches the
|
|
359
|
+
# Node subprocess. Callers that pass env=None (production) still get
|
|
360
|
+
# the ID injected; callers that pass a custom dict (tests) get their
|
|
361
|
+
# overrides merged on top.
|
|
362
|
+
merged_env = dict(os.environ)
|
|
363
|
+
if env is not None:
|
|
364
|
+
merged_env.update(env)
|
|
365
|
+
merged_env["QUARTZ_PARENT_BUILD_ID"] = build_id
|
|
366
|
+
# Suppress Node deprecation noise (e.g. DEP0040 punycode from a transitive
|
|
367
|
+
# Quartz dep). Cosmetic only; setdefault preserves a caller override.
|
|
368
|
+
merged_env.setdefault("NODE_OPTIONS", "--no-deprecation")
|
|
369
|
+
|
|
370
|
+
args = [
|
|
371
|
+
node_path,
|
|
372
|
+
str(workspace / "quartz" / "bootstrap-cli.mjs"),
|
|
373
|
+
"build",
|
|
374
|
+
"--directory",
|
|
375
|
+
str(vault),
|
|
376
|
+
"--output",
|
|
377
|
+
str(build_dir),
|
|
378
|
+
]
|
|
379
|
+
subprocess.run( # noqa: S603 — list-form args, no shell
|
|
380
|
+
args,
|
|
381
|
+
cwd=str(workspace),
|
|
382
|
+
check=True,
|
|
383
|
+
timeout=timeout_seconds,
|
|
384
|
+
env=merged_env, # always a dict (never None) — QUARTZ_PARENT_BUILD_ID injected above
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _atomic_swap(workspace: Path, build_id: str) -> None:
|
|
389
|
+
"""Retarget ``<workspace>/current`` at ``builds/<build_id>`` atomically.
|
|
390
|
+
|
|
391
|
+
Uses a temp-symlink + ``Path.replace`` (POSIX ``rename(2)``) so a
|
|
392
|
+
reader that opens ``current/.build-id`` mid-swap either sees the old
|
|
393
|
+
target or the new one — never a missing file. APFS, ext4, and ZFS
|
|
394
|
+
all guarantee rename atomicity for symlinks.
|
|
395
|
+
|
|
396
|
+
The relative target (``builds/<id>``) keeps the symlink portable
|
|
397
|
+
if the workspace is moved; absolute targets would need rewriting.
|
|
398
|
+
"""
|
|
399
|
+
target = Path("builds") / build_id
|
|
400
|
+
current = workspace / "current"
|
|
401
|
+
tmp = workspace / "current.tmp"
|
|
402
|
+
if tmp.is_symlink() or tmp.exists():
|
|
403
|
+
tmp.unlink()
|
|
404
|
+
tmp.symlink_to(target)
|
|
405
|
+
tmp.replace(current)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _garbage_collect(workspace: Path, *, keep: int) -> list[Path]:
|
|
409
|
+
"""Delete build dirs beyond the ``keep`` most-recent + the active one.
|
|
410
|
+
|
|
411
|
+
Sorting by mtime descending preserves the most recent ``keep`` even
|
|
412
|
+
if their names sort differently (clock skew / manual touches).
|
|
413
|
+
Whatever the symlink resolves to is *always* spared — without that
|
|
414
|
+
rule a ``keep=1`` config could delete the live build out from under
|
|
415
|
+
Caddy. Returns the list of pruned dirs so the CLI can log them.
|
|
416
|
+
"""
|
|
417
|
+
builds_root = workspace / "builds"
|
|
418
|
+
if not builds_root.is_dir():
|
|
419
|
+
return []
|
|
420
|
+
candidates = [p for p in builds_root.iterdir() if p.is_dir()]
|
|
421
|
+
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
422
|
+
|
|
423
|
+
active = _resolve_current_target(workspace)
|
|
424
|
+
keepers: set[Path] = set(candidates[:keep])
|
|
425
|
+
if active is not None:
|
|
426
|
+
keepers.add(active)
|
|
427
|
+
|
|
428
|
+
pruned: list[Path] = []
|
|
429
|
+
for p in candidates:
|
|
430
|
+
if p in keepers:
|
|
431
|
+
continue
|
|
432
|
+
try:
|
|
433
|
+
shutil.rmtree(p)
|
|
434
|
+
except OSError as exc:
|
|
435
|
+
# GC is best-effort — a stuck dir (open file handle on
|
|
436
|
+
# Windows, exotic permissions) shouldn't fail the swap. Log
|
|
437
|
+
# and move on; the next run gets another shot at it.
|
|
438
|
+
logger.warning("wiki build: failed to prune %s: %s", p, exc)
|
|
439
|
+
continue
|
|
440
|
+
pruned.append(p)
|
|
441
|
+
return pruned
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _resolve_current_target(workspace: Path) -> Path | None:
|
|
445
|
+
"""Return the absolute build dir ``current`` points at, or None.
|
|
446
|
+
|
|
447
|
+
None covers two cases: the symlink doesn't exist (first build), or
|
|
448
|
+
it points at a path that's already been deleted (rare race between
|
|
449
|
+
GC and a manual ``rm``). The caller treats both as "no active
|
|
450
|
+
build to spare from GC".
|
|
451
|
+
"""
|
|
452
|
+
current = workspace / "current"
|
|
453
|
+
if not current.is_symlink() and not current.exists():
|
|
454
|
+
return None
|
|
455
|
+
try:
|
|
456
|
+
resolved = current.resolve(strict=True)
|
|
457
|
+
except (OSError, RuntimeError):
|
|
458
|
+
return None
|
|
459
|
+
if not resolved.is_dir():
|
|
460
|
+
return None
|
|
461
|
+
return resolved
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# ---------------------------------------------------------------------------
|
|
465
|
+
# CLI entry point — ``python -m brain.wiki.build_swap``.
|
|
466
|
+
# ---------------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _replace_vault_path(cfg: Config, vault_path: Path) -> Config:
|
|
470
|
+
"""Return a copy of ``cfg`` with ``vault_path`` overridden.
|
|
471
|
+
|
|
472
|
+
``Config`` is frozen, so we use :func:`dataclasses.replace`. Lifted
|
|
473
|
+
into a helper so the call site in :func:`main` reads cleanly and so
|
|
474
|
+
a future Config field addition (which would otherwise break the
|
|
475
|
+
inline ``replace(...)`` call by omission) flags here in one spot.
|
|
476
|
+
"""
|
|
477
|
+
return dataclasses.replace(cfg, vault_path=vault_path)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _refresh_pre_build_adornments(
|
|
481
|
+
vault: Path, *, refresh_related_inline: bool = True
|
|
482
|
+
) -> None:
|
|
483
|
+
"""Best-effort generated wiki adornment refresh before any Quartz build.
|
|
484
|
+
|
|
485
|
+
``refresh_related_inline=False`` skips ``refresh_related`` (the heavy
|
|
486
|
+
hybrid-search recompute, ~70s on a 1100-doc vault). The build watcher
|
|
487
|
+
passes ``False`` and runs ``refresh_related`` on a background daemon
|
|
488
|
+
thread post-build to keep edit-to-UI latency down. ``bin/brain-rebuild``
|
|
489
|
+
keeps the default ``True`` so manual rebuilds always emit fresh
|
|
490
|
+
related-docs JSON synchronously.
|
|
491
|
+
"""
|
|
492
|
+
# P4.7/P5.1 — refresh generated wiki adornments before the build
|
|
493
|
+
# so the new build picks up the freshest recent rail and related-doc
|
|
494
|
+
# JSON. Failures here are logged-and-swallowed by the refresh helpers
|
|
495
|
+
# themselves (DB unreachable, missing fence, …) — these adornments are
|
|
496
|
+
# a courtesy, the build is the customer.
|
|
497
|
+
# Config loading is opt-in: a Config-load failure (no DATABASE_URL on
|
|
498
|
+
# PATH) shouldn't block the build either, so we wrap the import too.
|
|
499
|
+
try:
|
|
500
|
+
from ..config import Config, ConfigError
|
|
501
|
+
from .build_homepage import refresh_homepage
|
|
502
|
+
from .build_related import refresh_related
|
|
503
|
+
|
|
504
|
+
try:
|
|
505
|
+
cfg = Config.load()
|
|
506
|
+
except ConfigError as exc:
|
|
507
|
+
logger.warning(
|
|
508
|
+
"wiki pre-build refresh: Config.load failed (%s) — skipping refresh",
|
|
509
|
+
exc,
|
|
510
|
+
)
|
|
511
|
+
else:
|
|
512
|
+
target_vault = vault.expanduser().resolve()
|
|
513
|
+
# Honor explicit build vault overrides: the generated adornments
|
|
514
|
+
# must follow the tree being built, not the env default.
|
|
515
|
+
cfg_for_build = (
|
|
516
|
+
cfg
|
|
517
|
+
if cfg.vault_path == target_vault
|
|
518
|
+
else _replace_vault_path(cfg, target_vault)
|
|
519
|
+
)
|
|
520
|
+
refresh_homepage(cfg_for_build)
|
|
521
|
+
if refresh_related_inline:
|
|
522
|
+
refresh_related(cfg_for_build)
|
|
523
|
+
except Exception as exc: # noqa: BLE001 — refresh is best-effort
|
|
524
|
+
logger.warning("wiki pre-build refresh: unexpected failure: %s", exc)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def main(argv: list[str] | None = None) -> int:
|
|
528
|
+
"""Run a single ``build_and_swap`` from the command line.
|
|
529
|
+
|
|
530
|
+
Used by ``bin/brain-up`` (cold-start build, when ``current/`` doesn't
|
|
531
|
+
exist yet) and ``bin/brain-rebuild`` (forced rebuild) so those shell
|
|
532
|
+
scripts can stay shell-only — no Python import needed at the call
|
|
533
|
+
site.
|
|
534
|
+
|
|
535
|
+
Mirrors :func:`build_watcher.main` argument shape (``--vault``,
|
|
536
|
+
``--quartz-dir``, ``--keep``) so the two CLIs feel like siblings.
|
|
537
|
+
Returns 0 on success and 1 on any :class:`BrainWikiError` (build
|
|
538
|
+
failure or workspace problem). Bad arguments exit 2 via argparse.
|
|
539
|
+
"""
|
|
540
|
+
parser = argparse.ArgumentParser(
|
|
541
|
+
prog="brain.wiki.build_swap",
|
|
542
|
+
description="Run a single atomic Quartz build + symlink swap.",
|
|
543
|
+
)
|
|
544
|
+
parser.add_argument("--vault", required=True, type=Path, help="Vault root path.")
|
|
545
|
+
parser.add_argument(
|
|
546
|
+
"--quartz-dir",
|
|
547
|
+
type=Path,
|
|
548
|
+
default=None,
|
|
549
|
+
help="Quartz workspace dir (default: <vault>/.quartz).",
|
|
550
|
+
)
|
|
551
|
+
parser.add_argument(
|
|
552
|
+
"--keep",
|
|
553
|
+
type=int,
|
|
554
|
+
default=3,
|
|
555
|
+
help="How many old build dirs to retain after the swap (default: 3).",
|
|
556
|
+
)
|
|
557
|
+
args = parser.parse_args(argv)
|
|
558
|
+
|
|
559
|
+
logging.basicConfig(
|
|
560
|
+
level=logging.INFO,
|
|
561
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
vault: Path = args.vault.expanduser().resolve()
|
|
565
|
+
quartz_dir: Path | None = (
|
|
566
|
+
args.quartz_dir.expanduser().resolve() if args.quartz_dir is not None else None
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
try:
|
|
570
|
+
result = build_and_swap(vault, quartz_dir=quartz_dir, keep=args.keep)
|
|
571
|
+
except BrainWikiError as exc:
|
|
572
|
+
# Wrapped wiki errors — workspace missing, build subprocess
|
|
573
|
+
# exited non-zero, or timed out. Print a one-line message and
|
|
574
|
+
# exit 1 so callers (brain-up, brain-rebuild) can `|| exit 1`.
|
|
575
|
+
sys.stderr.write(f"build failed: {exc}\n")
|
|
576
|
+
return 1
|
|
577
|
+
|
|
578
|
+
sys.stdout.write(
|
|
579
|
+
f"{result.build_id} ({result.elapsed_seconds:.2f}s, method={result.method})\n"
|
|
580
|
+
)
|
|
581
|
+
return 0
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
if __name__ == "__main__": # pragma: no cover — exercised via subprocess in CLI
|
|
585
|
+
raise SystemExit(main(sys.argv[1:]))
|