VNote Agent Development Guide

July 20, 2026 · View on GitHub

Prerequisites

  • Git (with Git Bash on Windows)
  • CMake 3.20+
  • Qt 5.x or 6.x (with QtWebEngine)
  • C++14 compatible compiler (MSVC, GCC, Clang)
  • clang-format (optional, for automatic code formatting)

Setup

After cloning the repository, run the init script:

PlatformCommand
Linux/macOSbash scripts/init.sh
Windowsscripts\init.cmd

The init script:

  1. Initializes and updates git submodules recursively
  2. Installs pre-commit hook for automatic clang-format on staged C++ files
  3. Sets up vtextedit submodule pre-commit hook

Submodule Push Discipline (CRITICAL — read before every push)

VNote pins git submodules (libs/vxcore, libs/vtextedit, libs/QHotkey, libs/qwindowkit) to specific commits. When you change code inside a submodule, the parent repo records a new submodule pointer (a gitlink to a commit SHA). CI clones submodules from their own remotes — if the pinned commit only exists in your local submodule checkout, every CI job fails at the "Init Submodules" step with:

fatal: remote error: upload-pack: not our ref <sha>
fatal: Fetched in submodule path 'libs/vxcore', but it did not contain <sha>. Direct fetching of that commit failed.

This breaks all CI checks (Linux, Windows, macOS, TSan) before any build runs.

Rule: ALWAYS push the submodule FIRST, then the parent repo.

When a commit bumps a submodule pointer, the order is non-negotiable:

  1. Push the submodule remote first. From inside the submodule (e.g. cd libs/vxcore), push its branch so the pinned commit exists upstream:
    cd libs/vxcore
    git push origin HEAD:main      # or the appropriate branch
    cd ../..
    
  2. Then push the parent vnote repo. Only after the submodule commit is on its remote may you push the gitlink that references it.

To push both at once and let git verify submodules are reachable, use:

git push --recurse-submodules=on-demand

Or enforce it as a guard before any push (recommended once per clone):

git config push.recurseSubmodules check   # aborts the parent push if a submodule commit is unpushed

Before pushing, verify no submodule commit is stranded

# For each submodule, confirm local HEAD is not ahead of its remote:
git submodule foreach 'git status -sb'
# A line like "## main...origin/main [ahead 1]" means an UNPUSHED submodule commit — push it before pushing vnote.

# Confirm the pinned SHA exists on the submodule remote:
cd libs/vxcore && git branch -r --contains $(git rev-parse HEAD) && cd ../..
# Empty output = the commit is NOT on any remote branch yet. DO NOT push vnote until it is.

If CI is already failing with "not our ref", the fix is to push the missing submodule commit (do not roll back the parent pointer if newer parent commits depend on the new submodule API).

Build Commands

Release Build

mkdir build && cd build
cmake ..
cmake --build . --config Release

Debug Build

mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Debug
cmake --build . --config Debug

Windows (PowerShell)

New-Item -ItemType Directory -Force -Path build
Set-Location build
cmake .. -GNinja
cmake --build . --config Release

Clean Build

rm -rf build && mkdir build && cd build && cmake .. && cmake --build .

Packaging-Only Build (Skip Tests)

For CI builds that only produce artifacts and don't need to compile the test infrastructure (e.g., to avoid hard-coded Qt6 test code when building against Qt5), pass the VNOTE_BUILD_TESTS option:

cmake .. -DVNOTE_BUILD_TESTS=OFF
cmake --build .

Testing

VNote has tests in two separate locations with different registration helpers and build configurations. Pick the right one or your test will silently never run.

Parent VNote tests (tests/)

Built automatically with the parent project into build-debug/tests/<category>/. Registered via the add_qt_test() helper.

Add a new test:

  1. Create tests/<category>/test_yourthing.cpp (categories: controllers/, core/, gui/, integration/, models/, utils/, widgets/).
  2. Register in the same directory's CMakeLists.txt:
    add_qt_test(test_yourthing
      SOURCES
        test_yourthing.cpp
        ${CMAKE_SOURCE_DIR}/src/path/to/code_under_test.cpp
      LINKS
        core_services
        vxcore
      GUILESS  # omit if test needs QApplication / widgets
    )
    
  3. Reconfigure from repo root if tests/CMakeLists.txt itself changed: cmake -B build-debug.

Build + run:

cmake --build build-debug --config Debug --target test_yourthing
ctest --test-dir build-debug -C Debug -R "^test_yourthing$" --output-on-failure

vxcore submodule tests (libs/vxcore/tests/)

Separate build dir required — the parent build-debug/ does NOT compile vxcore tests. Use a dedicated build dir configured with -DVXCORE_BUILD_TESTS=ON. Each test is a standalone executable (file basename = test target name) registered via add_vxcore_test().

Add a new test:

  1. Create libs/vxcore/tests/test_yourthing.cpp with its own main(). Subtests are functions that return int (0 = pass); call them via RUN_TEST(...) from test_utils.h. Always start main() with vxcore_set_test_mode(1) so the test uses %TEMP%\vxcore_test* instead of real AppData.
  2. Register in libs/vxcore/tests/CMakeLists.txt:
    add_vxcore_test(test_yourthing)
    
    The helper auto-links vxcore and adds ${CMAKE_SOURCE_DIR}/src + third_party include dirs.
  3. Test executables CANNOT call vxcore-internal symbols that lack VXCORE_API — they live outside the DLL. If you need an internal helper (e.g., GetCurrentTimestampMillis()), re-derive it locally in the test file rather than exporting it.

Configure (once per machine, or after CMake version upgrade):

cmake -S libs/vxcore -B libs/vxcore/build_test -G "Visual Studio 17 2022" -A x64 -DVXCORE_BUILD_TESTS=ON

Build + run:

cmake --build libs/vxcore/build_test --config Debug --target test_yourthing
ctest --test-dir libs/vxcore/build_test -C Debug -R "^test_yourthing$" --output-on-failure

Universal rules

  • Anchor the ctest regex with ^...$ to avoid false matches (e.g., -R "^test_sync$" won't sweep in test_session_persistence).
  • After a CMake version upgrade, stale build dirs fail to reconfigure with errors like CMakeSystem.cmake.in does not exist. Delete the offending build dir and re-run the configure command from scratch — do NOT try to repair in place.
  • After modifying a touched module, run the FULL test target for that module (not just your new subtest) to catch regressions; vxcore tests print each subtest name to stdout so you can verify the new one ran.

Running execs that depend on VTextEdit.dll (CRITICAL — avoid false-positive smoke tests)

build-debug/src/vnote.exe and most build-debug/tests/<category>/test_*.exe execs link against VTextEdit.dll (built into build-debug/libs/vtextedit/src/) plus the Qt 6 runtime DLLs. Neither is automatically copied next to vnote.exe in this development build (only test-exec dirs get VTextEdit.dll copied via CMake target propagation). Launching vnote.exe without setting PATH first causes the Windows loader to pop a "VTextEdit.dll was not found" modal dialog BEFORE the process reaches WinMain.

This is a verification trap: when the loader dialog blocks the process, the OS reports the process as alive (it has a PID, is technically running), so naive checks like Start-Process … -PassThru + Sleep + HasExited return $false → you falsely conclude the binary started. The process is actually frozen in pre-WinMain limbo waiting for someone to click the dialog. Stop-Process -Force afterwards silently dismisses the dialog and the lie is preserved.

Correct smoke-test pattern (PowerShell):

# 1. Prepend Qt bin + VTextEdit dir to PATH so the loader resolves all DLLs.
$env:PATH = "C:/Qt/6.9.3/msvc2022_64/bin;" +
            "$PWD/build-debug/libs/vtextedit/src;" +
            $env:PATH

# 2. Launch vnote.exe.
$proc = Start-Process -FilePath "build-debug/src/vnote.exe" -PassThru -WindowStyle Hidden
Start-Sleep -Seconds 5

# 3. Verify the process actually loaded VTextEdit.dll — NOT just HasExited.
#    If the loader dialog blocked the process, $proc.Modules will throw or be empty.
$loaded = $false
try {
  $proc.Refresh()
  $loaded = ($proc.Modules | Where-Object { $_.ModuleName -ieq "VTextEdit.dll" }).Count -gt 0
} catch {}
if ($proc.HasExited) {
  Write-Output "FAIL: vnote.exe exited with code $($proc.ExitCode) within 5s"
} elseif (-not $loaded) {
  Write-Output "FAIL: vnote.exe alive but VTextEdit.dll not loaded — likely a loader dialog"
} else {
  Write-Output "PASS: vnote.exe alive with VTextEdit.dll loaded after 5s"
}
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue

The same PATH prepend is required before running parent test_*.exe directly (per tests/AGENTS.md). ctest --test-dir build-debug inherits the caller's PATH, so set it once at the start of the session.

vxcore submodule tests (libs/vxcore/build_test/bin/Debug/test_*.exe) do NOT depend on Qt or VTextEdit and need no PATH setup.


Architecture Overview

VNote uses a clean architecture with Model-View-Controller (MVC) pattern and dependency injection for testability and future plugin support.

Core Principles

  1. MVC Separation — Models hold data, Views display it, Controllers handle logic
  2. Dependency Injection — No singletons; dependencies passed via ServiceLocator
  3. Service Layer — Business logic encapsulated in services, accessed via ServiceLocator
  4. Hook System — WordPress-style extensibility for plugins

MVC Architecture (CRITICAL)

VNote strictly follows the MVC pattern. All new code MUST adhere to this structure.

┌─────────────────────────────────────────────────────────────┐
│                     Controllers                             │
│  (src/controllers/ - Handle user actions, business logic)   │
│                                                             │
│  NotebookNodeController, NewNoteController, etc.            │
└─────────────────────────────────────────────────────────────┘
        │                                       │
        │ Manipulates                          │ Emits signals to
        ▼                                       ▼
┌───────────────────────┐       ┌──────────────────────────────┐
│        Models         │       │           Views              │
│  (src/models/)        │◄──────│  (src/views/)                │
│                       │       │                              │
│  NotebookNodeModel    │ Data  │  NotebookNodeView            │
│  (QAbstractItemModel) │ flows │  (QTreeView subclass)        │
└───────────────────────┘       └──────────────────────────────┘
        │                                       │
        │ Fetches data from                    │ Receives from
        ▼                                       ▼
┌─────────────────────────────────────────────────────────────┐
│                     ServiceLocator                          │
│  (DI container - NOT a singleton, passed by reference)      │
│                                                             │
│  ┌─────────────────┐  ┌─────────────────────┐  ┌───────────────────┐  │
│  │ConfigCoreService│  │ NotebookCoreService │  │SearchCoreService  │  │
│  └─────────────────┘  └─────────────────────┘  └───────────────────┘  │
└─────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│                         vxcore                              │
│  (C library: notebook/config/search backend in libs/vxcore) │
└─────────────────────────────────────────────────────────────┘

MVC Responsibilities

LayerLocationResponsibilityExample
Modelsrc/models/Data representation, Qt Model/View integrationNotebookNodeModel exposes node hierarchy via QAbstractItemModel
Viewsrc/views/Display data, capture user input, emit signalsNotebookNodeView renders tree, emits nodeActivated signal
Controllersrc/controllers/Handle actions, orchestrate Model/View, business logicNotebookNodeController handles new/delete/rename operations
Servicesrc/core/services/Domain operations, data access via vxcoreNotebookCoreService wraps vxcore C API for notebook CRUD

MVC Example: Notebook Node Operations

// Controller handles user action
void NotebookNodeController::newNote(const NodeIdentifier &p_parentId) {
  // 1. Emit signal to View to show dialog
  emit newNoteRequested(p_parentId);
}

// View shows dialog, then calls back to Controller
void NotebookExplorer2::onNewNoteResult(const NodeIdentifier &p_parentId,
                                        const NodeIdentifier &p_newNodeId) {
  m_controller->handleNewNoteResult(p_parentId, p_newNodeId);
}

// Controller updates Model
void NotebookNodeController::handleNewNoteResult(const NodeIdentifier &p_parentId,
                                                  const NodeIdentifier &p_newNodeId) {
  // Model reloads from NotebookCoreService
  m_model->reloadNode(p_parentId);
}

MVC Rules (MUST FOLLOW)

RuleRationale
Models MUST NOT contain UI logicModels are reusable across different views
Views MUST NOT modify data directlyViews only display and emit signals
Controllers MUST NOT inherit from QWidgetControllers are testable without GUI
All layers receive ServiceLocator&Enables dependency injection and testing
Use signals/slots between layersLoose coupling between M, V, C

Key Design Decisions

DecisionRationale
ServiceLocator is NOT a singletonEnables testing with mock services; explicit dependencies
Services wrap vxcore C APIQt-friendly interface; encapsulates C interop
Controllers are QObject, not QWidgetTestable business logic without GUI dependencies
Widgets receive ServiceLocator&Constructor injection; no global state
Some files carry a 2 suffix (MainWindow2, Buffer2, …)Historical artifact of the now-complete migration off the legacy singleton architecture. The pre-migration counterparts have been removed; the suffix is retained on those existing classes only to avoid a churny rename. The migration is finished, so new code uses the plain, unsuffixed name unless it genuinely conflicts with an existing type (e.g. a brand-new EncodingButton gets no suffix). Never introduce a 3 suffix.
Buffer2 is a lightweight copyable handle (like QModelIndex)Returned by BufferService::openBuffer(), delegates to BufferCoreService; NOT a QObject, not heap-allocated
BufferService privately inherits BufferCoreServiceHook-aware wrapper that fires vnote.file.* hooks around core operations
NodeIdentifier is a standalone value typeIdentifies a node by notebookId + relativePath; used by Buffer2, controllers, and views
ConfigMgr2 is the ONLY way to access typed configOwns MainConfig/SessionConfig with properly merged defaults. NEVER construct a throwaway MainConfig from raw JSON — use m_services.get<ConfigMgr2>() instead. See src/core/AGENTS.md for details.
ConfigMgr2::getFileFromConfigFolder() for path resolutionResolves relative config paths (e.g., "web/markdown-viewer-template.html") against the app data directory. Do NOT use ConfigCoreService::getDataPath() + manual QDir::filePath().
PathExists()/IsDirectory()/IsRegularFile() wrappersNEVER pass raw std::string to std::filesystem — use these wrappers or PathFromUtf8() for non-ASCII path safety on Windows

Key Design Decisions (ViewArea2 Framework)

DecisionRationale
ViewAreaController is the orchestratorHandles open/close/split/move logic, fires hooks, uses WorkspaceCoreService
ViewArea2 is a pure viewOwns QSplitter tree + ViewSplit2 instances, no business logic
ViewSplit2 maps 1:1 to vxcore workspaceEach tab widget pane corresponds to one WorkspaceCoreService workspace
ViewWindow2 receives Buffer2 in constructorNot attach/detach pattern; one window = one buffer for its lifetime
ViewWindowFactory maps file types to creatorsRegistry pattern; plugins register creators for new file types
Splitter orientation follows Vim conventionLeft/Right split → Qt::Horizontal, Up/Down split → Qt::Vertical
Session layout stored as JSON in SessionConfigRecursive splitter tree + workspace IDs for full layout persistence
Concrete ViewWindows live alongside the frameworkMarkdownViewWindow2, TextViewWindow2, PdfViewWindow2, MindMapViewWindow2, and WidgetViewWindow2 are registered with ViewWindowFactory per file type

Directory Structure

src/
├── main.cpp                # Entry point with DI wiring
├── core/
│   ├── servicelocator.h    # DI container
│   ├── nodeidentifier.h    # Lightweight node ID (notebookId + relativePath)
│   ├── services/           # Service layer (wraps vxcore)
│   │   ├── configcoreservice.h/.cpp
│   │   ├── notebookcoreservice.h/.cpp
│   │   ├── searchcoreservice.h/.cpp
│   │   ├── filetypecoreservice.h/.cpp
│   │   ├── buffercoreservice.h/.cpp
│   │   ├── bufferservice.h/.cpp    # Hook-aware wrapper, returns Buffer2
│   │   ├── buffer2.h/.cpp          # Lightweight buffer handle (like QModelIndex)
│   │   ├── templateservice.h/.cpp
│   │   ├── historyservice.h/.cpp   # Aggregate per-notebook history across notebooks
│   │   ├── workspacecoreservice.h/.cpp  # Workspace operations (split pane ↔ vxcore workspace)
│   │   └── hookmanager.h/.cpp
│   ├── hookcontext.h       # Hook callback context
│   ├── hooknames.h         # Hook name constants
│   ├── configmgr2.h/.cpp   # High-level config manager using DI
│   └── iconfigmgr.h        # Interface for config managers
├── gui/                    # GUI-aware services and utilities
│   ├── services/
│   │   ├── themeservice.h/.cpp         # GUI-aware theme management service
│   │   └── viewwindowfactory.h/.cpp    # Registry mapping file types to ViewWindow2 creators
│   └── utils/
│       ├── widgetutils.h/.cpp          # Widget utility helpers
│       ├── themeutils.h/.cpp           # Theme utility helpers
│       ├── imageutils.h/.cpp           # Image utility helpers
│       └── guiutils.h/.cpp             # General GUI utilities
├── models/                 # Qt Model/View models
│   ├── notebooknodemodel.h/.cpp       # QAbstractItemModel for node hierarchy
│   └── notebooknodeproxymodel.h/.cpp  # Proxy model for sorting/filtering
├── views/                  # Qt views and delegates
│   ├── notebooknodeview.h/.cpp        # QTreeView for nodes
│   ├── notebooknodedelegate.h/.cpp    # Item delegate for node rendering
│   ├── combinednodeexplorer.h/.cpp    # Composite MVC wiring widget
│   └── filenodedelegate.h/.cpp        # Item delegate for file list
├── controllers/            # Controllers (business logic mediators)
│   ├── notebooknodecontroller.h/.cpp  # Node operations controller
│   ├── newnotecontroller.h/.cpp       # New note dialog controller
│   ├── newfoldercontroller.h/.cpp     # New folder dialog controller
│   ├── newnotebookcontroller.h/.cpp   # New notebook dialog controller
│   ├── opennotebookcontroller.h/.cpp  # Open notebook flow controller
│   ├── managenotebookscontroller.h/.cpp
│   ├── importfoldercontroller.h/.cpp
│   ├── recyclebincontroller.h/.cpp
│   └── viewareacontroller.h/.cpp      # View area orchestrator (open/close/split/move)
├── widgets/                # UI widgets (views receiving ServiceLocator&)
│   ├── mainwindow2.h/.cpp  # Main window shell
│   ├── notebookexplorer2.h/.cpp
│   ├── notebookselector2.h/.cpp
│   ├── toolbarhelper2.h/.cpp
│   ├── viewwindow2.h/.cpp  # Abstract base for file viewer windows
│   ├── markdownviewwindow2.h/.cpp  # Markdown editor/preview window
│   ├── textviewwindow2.h/.cpp      # Plain text editor window
│   ├── pdfviewwindow2.h/.cpp       # PDF viewer window
│   ├── mindmapviewwindow2.h/.cpp   # Mind map viewer window
│   ├── widgetviewwindow2.h/.cpp    # Generic widget-hosting window
│   ├── viewsplit2.h/.cpp   # QTabWidget-based split pane (one vxcore workspace)
│   ├── viewarea2.h/.cpp    # Splitter tree view (manages ViewSplit2 layout)
│   └── dialogs/            # Dialog widgets
│       ├── newnotedialog2.h/.cpp
│       ├── newfolderdialog2.h/.cpp
│       ├── newnotebookdialog2.h/.cpp
│       ├── managenotebooksdialog2.h/.cpp
│       └── importfolderdialog2.h/.cpp
├── utils/
│   └── fileutils2.h/.cpp   # File utilities
└── ...

Sync State Model

Threading rules: see libs/vxcore/src/sync/AGENTS.md § Threading & Callback Contract. Qt-side dispatch (single queue via SyncWorkQueueManager + SyncOps, coalescing, cancellation, auto-sync routing through EventBridge::syncShouldRun): see src/core/services/AGENTS.md § SyncService. The per-notebook autoSyncEnabled flag (boolean, default true) is a pure on/off gate inside vxcore's MaybeEnqueueSync: when false, vxcore suppresses sync.should_run entirely. It carries no cadence. Auto-sync cadence is owned Qt-side by SyncService, which applies a trailing-throttle debounce keyed off the global autoSyncDebounceSeconds app-config value (stored in vxcore's vxcore.json but consumed only by VNote).

Notebook sync has 8 reachable states (S0-S7). Every controller, widget, and service that touches sync must reason in terms of these states. The state is the tuple of: on-disk JSON sync fields, PAT presence in the OS keychain, and runtime registration in vxcore's states_ map.

Canonical State Predicates

StatesyncEnabled (JSON)syncBackend (JSON)syncRemoteUrl (JSON)PAT in keychainstates_ entry
S0false / absentabsentabsentabsentabsent
S1true"git"emptymaybeabsent
S2true"git"setabsentabsent
S3trueemptymaybemaybeabsent
S4true"git"setpresentabsent
S5true"git"setpresentpresent
S6falseabsentabsentpresentabsent
S7true"git"setpresentpresent + active sync

S5 is the only "ready" state. S1-S4 and S6 are partial/inconsistent; S0 is cleanly disabled; S7 is in-flight.

F3.5 in-flight sub-states (fetching/resolving/pushing) are NOT modeled as separate SyncState values, they remain runtime properties exposed by SyncService progress signals while the notebook is in S7.

Recovery Paths: bootstrapApply vs applyChanges

PathUse whenBehavior
NotebookSyncInfoController::bootstrapApply(url, pat)Notebook is in S1/S2/S3/S4 (any partial state). Atomic enable for an existing notebook.Calls SyncService::enableSyncForNotebook directly; on success persists syncRemoteUrl and triggers initial sync; on failure keeps notebook in current state (NO delete, unlike NewNotebookController::bootstrapSync).
NotebookSyncInfoController::applyChanges(url, pat)Notebook is in S5 (registered). PAT refresh or URL change.PAT-only update routes through SyncService::updateCredentials. URL change triggers confirmUrlChangeRequested signal and, on confirm, runs atomic disable+wipe vx_notebook/vx_sync/+re-enable.

The dialog (NotebookSyncInfoDialog2) auto-routes to bootstrapApply when m_bootstrapMode == true OR when SyncService::isSyncRegistered(id) == false. This is defense in depth: even when a caller bypasses the bootstrap entry point, partial-state notebooks still get the atomic path.

Reconcile Semantics

SyncService::reconcileSyncForNotebook is called by MainWindowAfterStart and on notebook open to lift S4 notebooks (disk-complete, runtime-absent) into S5.

Key invariants (src/core/services/syncservice.cpp:858-970):

  • m_reconcileAttempted.insert(id) happens after all precondition checks pass (line 893), not before. The disk-enabled check (line 869), idempotence guard (line 875), and complete-config check (line 884) all run first; any of them returning early leaves the attempted set untouched. Precondition failures therefore do NOT block future retries. Concrete consequence: a notebook in S1/S3 (enabled but no backend/url, or no backend) hits the incomplete config branch at line 885, emits reconcileFinished(VXCORE_ERR_INVALID_PARAM), and is NOT marked attempted. When the user later supplies the missing URL via bootstrapApply and the notebook reaches S4, the very next reconcile trigger (notebook open or app start) will pass the precondition check and proceed.
  • m_reconcileAttempted.remove(id) fires on transient PAT fetch failure (line 945) so the next reconcile call retries. The same key is re-cleared by updateCredentials (line 969) before manually re-driving reconcile, so a user re-entering a fresh PAT never hits the "already attempted" guard.
  • No remove on success (notebook is registered; no retry needed).
  • Idempotence check at line 875 prevents duplicate in-flight reconciles when MainWindowAfterStart and NotebookAfterOpen race.

Post-reconcile freshness gate (auto-sync on open / app start). After reconcile's SyncOps::enableSync work item returns VXCORE_OK, SyncService::maybeTriggerPostReconcile(notebookId) (src/core/services/syncservice.cpp) optionally enqueues a follow-up triggerSyncNow so the notebook is auto-synced when the user reopens VNote (or opens a notebook for the first time in the session) after remote changes. Closes the multi-device staleness window where reconcile alone only registered the notebook and the first FetchOrigin waited for the next save / manual Sync Now. The gate skips when: shutdown is in progress; the notebook is no longer enabled or registered; a sync is already in flight for the notebook; or the per-device last successful sync timestamp is newer than kPostReconcileFreshnessMs (2 minutes — covers rapid open/close cycles without thrashing). Both L1 (onMainWindowAfterStart, per-notebook sweep) and L2 (onNotebookAfterOpen, single notebook) inherit this behavior since both call reconcileSyncForNotebook. Full rationale and test seams live in src/core/services/AGENTS.md § "Post-reconcile freshness gate (maybeTriggerPostReconcile)".

bootstrapAndPersist Rollback × Reconcile

SyncService::bootstrapAndPersist (syncservice.cpp:409-508) is the atomic enable+persist path used by NewNotebookController (W13.4, F1.6). On persist failure AFTER vxcore enable already succeeded, it issues a rollback by calling disableSyncForNotebook, which per "Disable Cleanup" below clears the three flat sync JSON keys then deletes the keychain entry.

Interaction with reconcile:

  • Rollback succeeds (the common case): the notebook returns to clean S0. The disk-enabled check at reconcileSyncForNotebook line 869 fails immediately, the function early-returns, m_reconcileAttempted is never touched, and reconcile is correctly a no-op. The notebook needs a fresh user-initiated bootstrap, not silent re-registration. This is the intended recovery story.
  • Rollback fails (rare; both persist AND disable failed, logged at qCritical line 497): the notebook is left in a partial state. If JSON still has all three keys set (vxcore enable succeeded, JSON write succeeded for some keys, then disable failed leaving keys intact), the notebook is effectively in S4. On the next reconcile trigger, all precondition checks pass, m_reconcileAttempted gets set, and reconcile will attempt to register the notebook. This is the expected recovery path for that edge case; the loud qCritical log gives operators a chance to investigate.

The key property: rollback NEVER causes reconcile to silently resurrect a notebook the user wanted disabled. Either rollback succeeded (so disk is clean and reconcile bails) or rollback failed (so disk truthfully says "enabled" and reconcile correctly tries to complete the job).

Disable Cleanup

SyncService::disableSyncForNotebook on VXCORE_OK clears all three flat sync keys (syncEnabled, syncBackend, syncRemoteUrl) from notebook JSON BEFORE deleting the keychain entry (src/core/services/syncservice.cpp:246-290). On failure, JSON is preserved for retry. This closes the "resurrection trap" where a disabled notebook would reappear as S6 (orphan PAT) or S1 (orphan disk fields) on next app start.

For the full table of all five credential cleanup sites (bootstrap rollback, bootstrapAndPersist rollback, notebook removal, sync disable, S6 startup sweep) and the "when fires / when does NOT fire" matrix, see src/core/services/AGENTS.md § Credential Cleanup Invariants.

Startup S6 Sweep

SyncService::onMainWindowAfterStart (syncservice.cpp:828-854) sweeps S6 orphans before reconciling. For each notebook it iterates, if !isSyncEnabled(id) && m_credentialsStore->hasCredentials(id) (the S6 predicate: disk says disabled but a PAT is still in the keychain), it calls m_credentialsStore->deleteCredentials(id) to drop the orphan PAT. This handles the scenario where a previous session's disable succeeded inside vxcore but the app crashed (or was killed) before the keychain delete completed, leaving an orphan PAT that the new "disable clears JSON then keychain" ordering would otherwise not catch on its own.

The sweep runs BEFORE reconcileSyncForNotebook(nbId) on each notebook, so by the time reconcile examines the notebook the keychain state is consistent with disk truth.

Re-enable UI Affordance

S0 notebooks expose a re-enable surface via the same Sync button and Sync Info menu used for S5 (src/widgets/notebookexplorer2.cpp:1512-1635). For S0:

  • Button label: "Enable Sync" (distinct from "Sync Now" for S5)
  • On click: opens NotebookSyncInfoDialog2 with setBootstrapMode(true) and all fields empty
  • Sync Info menu item enabled regardless of syncEnabled (dialog opens in bootstrap mode with disable button hidden)

Without this affordance, users who disable sync cannot re-enable without recreating the notebook.

Sync Architecture Layers

VNote consumes vxcore as an embedded library following the contract documented in libs/vxcore/AGENTS.md § Library Integration Contract. Vxcore emits facts (events, dirty marks); VNote owns sync scheduling policy via SyncService + SyncWorkQueueManager (see src/core/services/AGENTS.md § SyncService). Vxcore must NOT contain Qt-side concerns (no QTimer, no QObject, no scheduling policy); VNote must NOT bypass the contract by reaching into vxcore internals (no direct backend calls, no touching libgit2, no states_ mutation). The 4-layer ownership table lives in the vxcore doc to avoid duplication.

Qt-side scheduling shape (post May 2026 audit, debounce added June 2026). SyncService::onSyncShouldRun no longer enqueues immediately on the auto-sync path. It keeps the shutdown / readiness / auth-circuit-breaker guards, then routes through a per-notebook trailing-throttle debounce: when the global cadence autoSyncDebounceSeconds is 0 it enqueues immediately, otherwise it arms a per-notebook single-shot QTimer (armOrIgnoreDebounce) whose delay is lastSyncMs + autoSyncDebounceSeconds*1000 - now. An already-active timer is kept (the burst is absorbed), and onDebounceTimeout re-reads the cadence and re-checks freshness at fire time, re-arming if the last sync is still inside the window before finally calling the shared enqueueAutoSync body (coalesceKey="trigger"). The cadence is read on demand from ConfigCoreService::getAutoSyncDebounceSeconds() (clamped [0, 86400]), so config edits take effect without restart. Manual "Sync Now" (triggerSyncNow) and the post-reconcile freshness trigger BYPASS the debounce entirely. The coalesce key still dedupes whatever lands in the queue. The debounce lives one layer ABOVE SyncWorkQueueManager, so queue semantics are unchanged. vxcore's per-notebook autoSyncEnabled is a boolean on/off gate only (it suppresses sync.should_run when false) and carries no schedule.


Save Path Threading Contract

The Buffer2 / BufferService auto-save path used to call vxcore_buffer_save inline on the UI thread, so any slow filesystem operation (large file flush, virus scanner, network drive, antivirus quarantine) froze the editor. That synchronous call now runs on a worker via BufferSaveQueue. The UI thread's job is reduced to: snapshot the current content plus a monotonically increasing revision, call BufferSaveQueue::enqueue(...), and return. No disk I/O on the UI thread.

Save work and any git-stage / git-commit work on the SAME notebook are serialized by NotebookIoGate, a per-notebook async mutex. BufferSaveQueue workers acquire NotebookIoGate::ScopedLock(notebookId) for the full duration of their disk write. SyncOps::triggerSync now runs sync as two phases: it holds the gate ONLY around vxcore_sync_stage_only (StageAll + CommitIndex, working-tree-touching), then releases it BEFORE calling vxcore_sync_network_phase (FetchOrigin + RebaseOntoOrigin + PushOrigin). The result: a sync never reads a half-written file, a save never lands inside someone else's git add/commit, AND a queued save on the same notebook resumes the moment the local commit lands, regardless of how long the network round-trip takes.

vxcore's mark_dirtyMaybeEnqueueSyncEmit("sync.should_run") chain remains synchronous on the caller thread BY DESIGN. In steady state it is microseconds, and pushing it onto another thread would buy nothing while costing event-ordering guarantees. This contract does NOT change vxcore. The threading discipline is consumer-side only: keep vxcore_buffer_save off the UI thread, and the mark_dirty tail it triggers stays off the UI thread for free.

Forbidden Patterns (post-T7):

  • Calling vxcore_buffer_save directly from the UI thread. Use BufferSaveQueue::enqueue instead.
  • Touching a notebook's working tree (save, stage, commit, checkout) without holding NotebookIoGate::ScopedLock(notebookId).

Search Threading Contract

Content search in vxcore owns NO thread pool. As of the streaming-search work, vxcore_search_content / vxcore_search_content_ex / vxcore_search_content_streaming enqueue ONE work item per FILE-CHUNK (default kDefaultSearchChunkSize = 64 files, tunable via the streaming batch_size parameter) onto a dedicated "vxcore.search" WorkQueue that is pre-created at vxcore_context_create. The CALLER's threads drain that queue, and the initiating thread help-drains its own enqueued items (caller-helps-drain: it loops ProcessNext(5ms) until the batch is done). VNote's SearchService owns the drain pool that loops vxcore_work_queue_process_next(ctx, "vxcore.search", 100). A search that fits in a single chunk (fileCount <= batch_size, i.e. ≤ 64 files by default) runs inline sequentially on the calling thread; larger searches fan out one queued item per chunk. Chunking subsumes the former kParallelSearchThreshold (was 50 files): the chunk boundary is now both the parallelism unit AND the incremental-delivery unit. This coarsens parallelism granularity for medium searches versus the old per-file fan-out — an accepted tradeoff to unify the blocking and streaming code paths; lower batch_size for finer-grained parallelism.

The initiating thread's self-drain is the correctness floor: a consumer that provides NO external drain threads still gets correct, single-threaded results, and extra drainers only add parallelism. Cancellation, max_results, and result ordering are preserved across both paths; an exception thrown mid-scan is caught and surfaces as VXCORE_ERR_UNKNOWN.

This mirrors the vxcore/VNote ownership split used by sync: vxcore emits per-file search work as facts, VNote owns the drain policy. No vxcore-owned threads remain, the former BS::thread_pool search pool having been removed.


Code Style Guidelines

Standards

  • C++14 standard
  • Qt 5/6 framework
  • CMake with CMAKE_AUTOMOC, CMAKE_AUTOUIC, CMAKE_AUTORCC enabled

Formatting

  • 2-space indentation
  • 100 character line limit
  • Pointer alignment right: int *ptr, not int* ptr
  • Use provided .clang-format (auto-applied via pre-commit hook)

Naming Conventions

ElementConventionExample
ClassesCamelCaseConfigMgr, MainWindow
MethodscamelCasegetInst(), initLoad()
Parametersp_ prefixp_parent, p_config
Membersm_ prefixm_themeMgr, m_config
Constantsc_ prefixc_orgName, c_appName
Gettersget prefixgetThemeMgr(), getName()

Include Order

#include "ownheader.h"      // Own header first

#include <QDateTime>        // Qt includes
#include <QObject>

#include "localheader.h"    // Local includes
#include <core/configmgr.h>
#include <utils/utils.h>

using namespace vnotex;     // Namespace declaration in .cpp

Header Guards

#ifndef CLASSNAME_H
#define CLASSNAME_H
// ...
#endif // CLASSNAME_H

Namespaces

VNote uses a single vnotex namespace. Services that wrap the vxcore C library use the CoreService suffix to distinguish them from higher-level wrapper services:

Class PatternPurposeExamples
XXXCoreServiceLow-level services that wrap the vxcore C library (hold VxCoreContextHandle)ConfigCoreService, NotebookCoreService, BufferCoreService, SearchCoreService, FileTypeCoreService
Other classesEverything else: UI, controllers, models, hook-aware wrapper servicesBufferService (hook wrapper), HookManager, TemplateService, ConfigMgr2, controllers, widgets

Rules:

  • using namespace vnotex; in .cpp files only, never in headers
  • Forward declarations preferred in headers

Common Patterns

Noncopyable Pattern

// src/core/noncopyable.h
class Noncopyable {
protected:
  Noncopyable() = default;
  virtual ~Noncopyable() = default;
  Noncopyable(const Noncopyable &) = delete;
  Noncopyable &operator=(const Noncopyable &) = delete;
};

// Usage: private inheritance
class MyClass : public QObject, private Noncopyable {
  // ...
};

Deprecation Macro

Use VNOTEX_DEPRECATED(msg) from src/core/global.h to mark legacy classes, structs, and functions. It expands to [[deprecated(msg)]] on C++14+ (MSVC and GCC/Clang), or is a no-op on older compilers.

#include <core/global.h>

// Deprecate a class — place between 'class' keyword and class name
class VNOTEX_DEPRECATED("Use MainWindow2 with ServiceLocator pattern instead") MainWindow
    : public QMainWindow {
  // ...
};

// Deprecate a struct
struct VNOTEX_DEPRECATED("Use FileOpenSettings instead") FileOpenParameters {
  // ...
};

// Deprecate a function
VNOTEX_DEPRECATED("Use newMethod() instead")
void oldMethod();

Rules:

  • Always include a migration message explaining what to use instead
  • For type deprecation, place the macro between the class/struct keyword and the type name
  • Requires #include <core/global.h>

Exception Handling

// src/core/exception.h
class Exception : virtual public std::runtime_error {
public:
  enum class Type {
    InvalidPath, FailToCreateDir, FailToWriteFile,
    FailToReadFile, FailToRenameFile, /* ... */
  };

  [[noreturn]] static void throwOne(Type p_type, const QString &p_what) {
    qCritical() << typeToString(p_type) << p_what;
    throw Exception(p_type, p_what);
  }
};

// Usage
Exception::throwOne(Exception::Type::FailToReadFile, "Cannot read config");

Signal/Slot Connections

// Preferred: new Qt5 syntax
connect(m_taskMgr, &TaskMgr::taskOutputRequested,
        this, &VNoteX::showOutputRequested);

// With overloaded methods
connect(this, &VNoteX::openNodeRequested, m_bufferMgr,
        QOverload<Node *, const QSharedPointer<FileOpenParameters> &>::of(&BufferMgr::open));

Memory Management

// Use Qt smart pointers
QScopedPointer<MainConfig> m_config;
QSharedPointer<Task> task;

// QObject parent-child for automatic cleanup
m_themeMgr = new ThemeMgr(this);  // 'this' takes ownership

Widget Construction Pattern

The migration off the legacy singleton architecture (VNoteX::getInst(), ConfigMgr::getInst(), the legacy MainWindow/Buffer) is complete; those types have been removed. All widgets, controllers, models, and views take their dependencies via constructor injection with ServiceLocator&. Follow this pattern for new code:

// Receives dependencies via constructor
class MyWidget : public QWidget {
  Q_OBJECT
public:
  explicit MyWidget(ServiceLocator &p_services, QWidget *p_parent = nullptr);

private:
  ServiceLocator &m_services;

  void doSomething() {
    auto &config = m_services.get<ConfigCoreService>();
    auto &notebooks = m_services.get<NotebookCoreService>();
  }
};

Checklist for a new widget

  • Add ServiceLocator &p_services as the first constructor parameter
  • Store reference: ServiceLocator &m_services
  • Resolve dependencies via m_services.get<XxxCoreService>() — never global state
  • Have the parent widget pass its ServiceLocator& down
  • Register the file in the relevant CMakeLists.txt
  • Write a unit test with mock services

Logging

Use Qt logging macros:

qDebug() << "Debug message";
qInfo() << "Info message";
qWarning() << "Warning message";
qCritical() << "Critical error";

Code Formatting

The pre-commit hook automatically formats staged C++ files using clang-format.

Manual formatting:

clang-format -i src/core/myfile.cpp

Excluded from formatting: libs/ directory (third-party code)


Shared JSON Keys (SSOT)

Cross-boundary JSON keys (vxcore↔Qt) live in <vxcore/notebook_json_keys.h>. See libs/vxcore/AGENTS.md § JSON Conventions for the SSOT contract and the test_json_key_drift regression gate.


Module Documentation Index

Detailed knowledge for each module lives in its own AGENTS.md:

ModuleFileDescription
Core & Servicessrc/core/AGENTS.mdServiceLocator, DI, Buffer2, hooks, adding services
Controllerssrc/controllers/AGENTS.mdController patterns, MVC rules for controllers
Modelssrc/models/AGENTS.mdQt Model/View data representations
Viewssrc/views/AGENTS.mdView conventions, delegate patterns
Widgetssrc/widgets/AGENTS.mdWidget conventions, ViewArea2 framework
GUI Servicessrc/gui/AGENTS.mdTheme, ViewWindowFactory, GUI utilities
Utilitiessrc/utils/AGENTS.mdPathUtils, HtmlUtils, FileUtils2 reference
Testingtests/AGENTS.mdTest infrastructure, test mode, coverage
vxcore (submodule)libs/vxcore/AGENTS.mdC library: notebook/config/search backend
vxcore Synclibs/vxcore/src/sync/AGENTS.mdPluggable sync backend interface (ISyncBackend, SyncManager)
vtextedit (submodule)libs/vtextedit/AGENTS.mdQt editor widget library