diff --git a/README.md b/README.md index 84193f184..e341cc681 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,8 @@ codebase-memory-mcp config set auto_index true When enabled, new projects are indexed automatically on first connection. Previously-indexed projects are registered with the background watcher for ongoing git-based change detection. Configurable file limit: `config set auto_index_limit 50000`. +The watcher follows MCP sessions: it watches the project each open MCP session is rooted in (the client's working directory) and stops when the last session for that project closes. A repository indexed with `cli index_repository`, or indexed from a session rooted somewhere else, is not watched — re-run `index_repository` after changing it. `index_status` reports this in its `watch` object: `watched`, and either the poll cadence and last scan time or the `reason` it is not watched. + Watcher registration is controlled separately by `auto_watch` (default `true`). Set `config set auto_watch false` to keep a session from registering its project with the background watcher — useful when working across many projects and you want each session contained to explicit indexing. To turn the watcher off entirely, set `config set watcher_enabled false` (default `true`): the background poll thread never starts and no project is registered, while `auto_index` and manual `index_repository` keep working. Unlike `auto_watch` — which is consulted per session — `watcher_enabled` is read once when the background daemon starts, so run `codebase-memory-mcp daemon stop` after changing it; reconnecting your MCP client alone will not restart the daemon. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md#2-cli-managed-runtime-settings). @@ -246,7 +248,7 @@ The install script placed beside the binary is **reported, not deleted** — uni ### Distribution & operation - **Native runtime set, zero infrastructure services**: SQLite-backed, persists to `~/.cache/codebase-memory-mcp/` -- **Auto-sync**: Background watcher detects file changes and re-indexes automatically +- **Auto-sync**: Background watcher detects file changes in the project an MCP session is working in and re-indexes it automatically (other indexed repos: re-run `index_repository`) - **Route nodes**: REST endpoints are first-class graph entities - **CLI mode**: `codebase-memory-mcp cli search_graph '{"project": "my-project", "name_pattern": ".*Handler.*"}'` - **Available on**: npm, PyPI, Homebrew, Scoop, Winget, Chocolatey, AUR, `go install` @@ -681,7 +683,7 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool | Tool | Description | |------|-------------| -| `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh after that. Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). | +| `index_repository` | Index a repository into the graph. Auto-sync keeps it fresh only while it is the project of an open MCP session; repos indexed from the CLI or from another project's session need a re-run (`index_status` shows `watch`). Waits for the whole index by default; pass `async: true` to start it in the daemon and return at once, then poll with `status: true` (see below). | | `list_projects` | List all indexed projects with node/edge counts. | | `delete_project` | Remove a project and all its graph data. | | `index_status` | Check indexing status of a project. | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0099efb52..0ee1f41eb 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -88,11 +88,17 @@ Current keys: | `auto_index` | `false` | Automatically index new projects when an MCP session starts. | | `auto_index_limit` | `50000` | Maximum file count allowed for automatic indexing of a new project. | | `auto_watch` | `true` | Register the session's project with the background git watcher on connect. Set `false` to keep a session from registering its project (the watcher still runs for other projects). | -| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. | +| `watcher_enabled` | `true` | Master switch for the background watcher subsystem. Set `false` to stop the watcher from starting at all — no poll thread and no project registration. Reindex manually with `index_repository` when disabled. Only an open MCP session's own project is ever watched; `index_status` reports the current state in its `watch` object. | | `watch_non_git` | `false` | Also poll project roots that are **not git repositories**. By default the watcher only follows git projects, so a project indexed from a plain directory is never refreshed after its first index — reindex it manually with `index_repository`. Set `true` to poll such roots on the same adaptive cadence with a file-tree scan: the indexer's own discovery walk (same skip lists, `.gitignore` and `.cbmignore` rules) hashed over each file's path, size and mtime. Any change reindexes once; paths the indexer skips (including cbm's own `.codebase-memory/` output) never trigger. The first poll after the daemon starts reindexes each such project once, since nothing records which tree state the index holds. The scan walks the whole tree every poll, so it costs more than git polling on very large trees. Read once when the daemon starts, like `watcher_enabled`. | | `index_max_files` | `off` | Optional maximum number of accepted source files in one discovery run. | | `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. | +For a watched project, `index_status` names the polling method in `watch.strategy`: +`git` (HEAD and dirty-state polling), `tree` (a non-git root polled by the +`watch_non_git` file-tree scan), `none` (a non-git root that is registered but never +polled, the default for a plain directory) or `pending` (registered; the first poll +has not run yet). + > **`watcher_enabled` vs `auto_watch`.** `watcher_enabled` controls whether the > watcher *subsystem* starts at all (the background poll thread). `auto_watch` is > narrower: it only controls whether a connecting session registers *its own* diff --git a/docs/index.html b/docs/index.html index fdfde941e..2add87b46 100644 --- a/docs/index.html +++ b/docs/index.html @@ -728,7 +728,7 @@

Infrastructure-as-code indexing

Auto-sync

-

A background watcher detects changes and re-indexes incrementally. No manual reindex after editing files.

+

A background watcher detects changes in the project your MCP session is working in and re-indexes it incrementally. No manual reindex after editing files there.

Team-shared graph artifact

diff --git a/src/daemon/application.c b/src/daemon/application.c index bdc6437d1..0dd236024 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -565,6 +565,57 @@ static void application_refresh_watch_locked(cbm_daemon_application_session_t *s session->watch = watch; } +static const char *application_watch_strategy_name(cbm_watcher_strategy_t strategy) { + switch (strategy) { + case CBM_WATCHER_STRATEGY_GIT: + return "git"; + case CBM_WATCHER_STRATEGY_TREE: + return "tree"; + case CBM_WATCHER_STRATEGY_NONE: + return "none"; + default: + return "pending"; + } +} + +/* index_status watch visibility (#2167). The physical watcher is the truth for + * "watched"; the reasons explain the documented scope: only an MCP session's + * own project is watched, and only while that session is open. Takes the + * application mutex and the watcher lock one after the other, never nested. */ +static void application_watch_status(void *context, const char *project, + cbm_mcp_watch_status_t *out) { + cbm_daemon_application_session_t *session = context; + cbm_daemon_application_t *application = session ? session->application : NULL; + if (!application || !application->watcher) { + out->reason = "watcher_disabled"; + return; + } + cbm_watcher_project_info_t info; + if (cbm_watcher_project_info(application->watcher, project, &info)) { + out->watched = true; + out->strategy = application_watch_strategy_name(info.strategy); + out->poll_interval_ms = info.poll_interval_ms; + out->last_scan_unix_s = info.last_scan_unix_s; + return; + } + const char *session_project = cbm_mcp_server_session_project(session->mcp); + if (!session_project || strcmp(session_project, project) != 0) { + out->reason = "not_session_project"; + return; + } + if (application->config && + !cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true)) { + out->reason = "auto_watch_off"; + return; + } + /* Only MCP sessions run background initialization; a one-shot CLI command + * never holds a watch across calls. */ + cbm_mutex_lock(&application->mutex); + bool mcp_session = session->background_eligible; + cbm_mutex_unlock(&application->mutex); + out->reason = mcp_session ? "not_registered" : "cli_session"; +} + static void application_refresh_watch(cbm_daemon_application_session_t *session) { if (!session || !session->application) { return; @@ -2798,6 +2849,7 @@ static cbm_daemon_runtime_application_session_t *application_session_open( } cbm_mcp_server_set_background_tasks(session->mcp, false); cbm_mcp_server_set_config(session->mcp, application->config); + cbm_mcp_server_set_watch_status_provider(session->mcp, application_watch_status, session); cbm_mcp_server_set_index_executor(session->mcp, application_index_execute, session); cbm_mcp_server_set_index_status_provider(session->mcp, application_index_status, session); cbm_mcp_server_set_project_mutation_guard(session->mcp, application_session_mutation_begin, diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index fb7dbf2c5..a147ac027 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -749,8 +749,9 @@ static const tool_def_t TOOLS[] = { "\"project\"]}"}, {"index_status", - "Project readiness, counts, root, and coverage gaps. diagnostics adds coverage rows; verbose " - "adds Git paths. Best-effort only; verify cited paths with check_index_coverage.", + "Project readiness, counts, root, watch state, and coverage gaps. diagnostics adds coverage " + "rows; verbose adds Git paths. Best-effort only; verify cited paths with " + "check_index_coverage.", "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}," "\"verbose\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Add worktree/" "shadow Git paths for index-location debugging.\"}," @@ -1409,7 +1410,8 @@ static const int SUPPORTED_VERSION_COUNT = static const char MCP_SERVER_INSTRUCTIONS[] = "Graph first: search_graph for symbols, trace_path for relationships, get_code_snippet for " "source, query_graph for multi-hop, and get_architecture for overview. Use search_code/grep " - "for literals or coverage gaps. Indexes auto-refresh. Check cited-path coverage; paginate."; + "for literals or coverage gaps. The session project's index auto-refreshes; re-index others. " + "Check cited-path coverage; paginate."; static const char MCP_ANALYSIS_SERVER_INSTRUCTIONS[] = "analysis tool profile: read-only graph work via search_graph, trace_path, " @@ -1791,6 +1793,9 @@ struct cbm_mcp_server { bool background_tasks; /* per-server update/auto-index work enabled */ struct cbm_watcher *watcher; /* external watcher ref (not owned) */ struct cbm_config *config; /* external config ref (not owned) */ + /* index_status watch visibility (#2167) */ + cbm_mcp_watch_status_fn watch_status_fn; + void *watch_status_context; cbm_mcp_index_executor_fn index_executor; void *index_executor_context; cbm_mcp_index_status_fn index_status_provider; /* #2144; NULL outside the daemon */ @@ -1878,6 +1883,14 @@ void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w) { } } +void cbm_mcp_server_set_watch_status_provider(cbm_mcp_server_t *srv, cbm_mcp_watch_status_fn fn, + void *context) { + if (srv) { + srv->watch_status_fn = fn; + srv->watch_status_context = context; + } +} + void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) { if (srv) { srv->config = cfg; @@ -6896,6 +6909,40 @@ static char *handle_check_index_coverage(cbm_mcp_server_t *srv, const char *args return result; } +/* index_status "watch" object (#2167): is this project kept fresh by the + * background watcher right now, and if not, why. Auto-sync covers the active + * MCP session's project only; anything else needs index_repository again. */ +static void add_watch_status_json(cbm_mcp_server_t *srv, yyjson_mut_doc *doc, yyjson_mut_val *root, + const char *project) { + cbm_mcp_watch_status_t status = {0}; + if (srv->watch_status_fn) { + srv->watch_status_fn(srv->watch_status_context, project, &status); + } else { + status.reason = "no_watcher"; + } + yyjson_mut_val *watch = yyjson_mut_obj(doc); + yyjson_mut_obj_add_bool(doc, watch, "watched", status.watched); + if (status.watched) { + yyjson_mut_obj_add_str(doc, watch, "strategy", status.strategy ? status.strategy : ""); + yyjson_mut_obj_add_int(doc, watch, "poll_interval_ms", status.poll_interval_ms); + if (status.last_scan_unix_s > 0) { + char when[CBM_SZ_32]; + time_t t = (time_t)status.last_scan_unix_s; + struct tm tm; + cbm_gmtime_r(&t, &tm); + if (strftime(when, sizeof(when), "%Y-%m-%dT%H:%M:%SZ", &tm) > 0) { + yyjson_mut_obj_add_strcpy(doc, watch, "last_scan_at", when); + } + } + } else { + yyjson_mut_obj_add_str(doc, watch, "reason", status.reason ? status.reason : "unknown"); + yyjson_mut_obj_add_str(doc, watch, "hint", + "Auto-sync watches only the active MCP session's project; " + "re-run index_repository to refresh this index."); + } + yyjson_mut_obj_add_val(doc, root, "watch", watch); +} + static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { char *project = get_project_arg(args); cbm_store_t *store = resolve_store(srv, project); @@ -6967,6 +7014,7 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { doc, root, "hint", "Project is empty. Re-run index_repository(repo_path=...) to populate."); } + add_watch_status_json(srv, doc, root, project); } else { yyjson_mut_obj_add_str(doc, root, "status", "no_project"); } diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index 5701067ee..3a5fce5ff 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -179,6 +179,22 @@ void cbm_mcp_server_free(cbm_mcp_server_t *srv); /* Set external watcher reference (for auto-index registration). Not owned. */ void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w); +/* Watch visibility for index_status (#2167). The daemon, which owns the one + * background watcher, answers whether `project` is currently watched. All + * strings are static. A server without a provider runs no watcher at all and + * reports watched=false with reason "no_watcher". */ +typedef struct { + bool watched; + const char *reason; /* why not watched (set when !watched) */ + const char *strategy; /* "pending" | "git" | "tree" | "none" (set when watched) */ + int poll_interval_ms; /* current adaptive cadence (when watched) */ + int64_t last_scan_unix_s; /* last completed scan, wall clock; 0 = none yet */ +} cbm_mcp_watch_status_t; +typedef void (*cbm_mcp_watch_status_fn)(void *context, const char *project, + cbm_mcp_watch_status_t *out); +void cbm_mcp_server_set_watch_status_provider(cbm_mcp_server_t *srv, cbm_mcp_watch_status_fn fn, + void *context); + /* Set external config store reference (for auto_index setting). Not owned. */ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg); diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index a83cab31a..efb46ac72 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -99,6 +99,12 @@ typedef struct { * from `rev-parse --show-cdup`. Porcelain paths are repository-relative, so * the signature needs this to stat them. Resolved once at baseline. */ char repo_cdup[CBM_SZ_4K]; + /* Published status for cbm_watcher_project_info (#2167). The poll path + * writes these while index_status reads them from a session thread, so + * they are atomics: the diagnostic read is never a data race. */ + atomic_int status_strategy; /* cbm_watcher_strategy_t */ + atomic_int status_interval_ms; /* mirrors interval_ms */ + _Atomic int64_t status_last_scan_s; /* wall-clock seconds; 0 = none yet */ } project_state_t; /* ── Watcher struct ─────────────────────────────────────────────── */ @@ -922,6 +928,9 @@ static project_state_t *state_new(const char *name, const char *root_path) { } atomic_init(&s->registered, true); s->interval_ms = POLL_BASE_MS; + atomic_init(&s->status_strategy, CBM_WATCHER_STRATEGY_PENDING); + atomic_init(&s->status_interval_ms, POLL_BASE_MS); + atomic_init(&s->status_last_scan_s, 0); return s; } @@ -1262,6 +1271,25 @@ int cbm_watcher_index_failure_count(cbm_watcher_t *w, const char *project_name) return failures; } +bool cbm_watcher_project_info(cbm_watcher_t *w, const char *project_name, + cbm_watcher_project_info_t *out) { + if (out) { + memset(out, 0, sizeof(*out)); + } + if (!w || !project_name || !out) { + return false; + } + cbm_mutex_lock(&w->projects_lock); + project_state_t *s = cbm_ht_get(w->projects, project_name); + if (s) { + out->strategy = (cbm_watcher_strategy_t)atomic_load(&s->status_strategy); + out->poll_interval_ms = atomic_load(&s->status_interval_ms); + out->last_scan_unix_s = atomic_load(&s->status_last_scan_s); + } + cbm_mutex_unlock(&w->projects_lock); + return s != NULL; +} + int cbm_watcher_watch_count(cbm_watcher_t *w) { if (!w) { return 0; @@ -1274,6 +1302,23 @@ int cbm_watcher_watch_count(cbm_watcher_t *w) { /* ── Single poll cycle ──────────────────────────────────────────── */ +/* Publish what index_status reports: the strategy, the current cadence and, + * when a check just completed, the wall-clock time of that scan. A non-git + * root is "tree" when watch_non_git polls it (#1948), else "none". */ +static void publish_status(project_state_t *s, bool scanned) { + cbm_watcher_strategy_t strategy = CBM_WATCHER_STRATEGY_NONE; + if (s->is_git) { + strategy = CBM_WATCHER_STRATEGY_GIT; + } else if (s->tree_poll) { + strategy = CBM_WATCHER_STRATEGY_TREE; + } + atomic_store(&s->status_strategy, strategy); + atomic_store(&s->status_interval_ms, s->interval_ms); + if (scanned) { + atomic_store(&s->status_last_scan_s, (int64_t)time(NULL)); + } +} + /* Init baseline for a project: check if git, get HEAD, count files */ static bool init_baseline(cbm_watcher_t *w, project_state_t *s) { struct stat st; @@ -1281,6 +1326,7 @@ static bool init_baseline(cbm_watcher_t *w, project_state_t *s) { cbm_log_warn("watcher.root_gone", "project", s->project_name, "path", s->root_path); s->baseline_done = true; s->is_git = false; + publish_status(s, false); return true; } @@ -1358,6 +1404,7 @@ static bool init_baseline(cbm_watcher_t *w, project_state_t *s) { } s->next_poll_ns = now_ns() + ((int64_t)s->interval_ms * US_PER_MS); + publish_status(s, true); return true; } @@ -1524,6 +1571,7 @@ static void commit_baselines(cbm_watcher_t *w, project_state_t *s) { if (git_file_count(w, s, &file_count) == WATCHER_GIT_OK) { s->file_count = file_count; s->interval_ms = cbm_watcher_poll_interval_ms(s->file_count); + publish_status(s, false); } } @@ -1595,6 +1643,7 @@ static void poll_project(const char *key, void *val, void *ud) { if (!check_changes(ctx->w, s, &changed)) { return; } + publish_status(s, true); if (!changed) { s->next_poll_ns = ctx->now + ((int64_t)s->interval_ms * US_PER_MS); return; diff --git a/src/watcher/watcher.h b/src/watcher/watcher.h index 6e8410138..0959853ae 100644 --- a/src/watcher/watcher.h +++ b/src/watcher/watcher.h @@ -130,6 +130,26 @@ int cbm_watcher_index_failure_count(cbm_watcher_t *w, const char *project_name); int cbm_watcher_test_pending_free_count(cbm_watcher_t *w); #endif +/* Per-project watch status, for index_status (#2167). */ +typedef enum { + CBM_WATCHER_STRATEGY_PENDING = 0, /* registered; first poll not done yet */ + CBM_WATCHER_STRATEGY_GIT = 1, /* git HEAD + dirty-state polling */ + CBM_WATCHER_STRATEGY_NONE = 2, /* not a git project: never polled */ + CBM_WATCHER_STRATEGY_TREE = 3, /* not a git project: file-tree polling (watch_non_git) */ +} cbm_watcher_strategy_t; + +typedef struct { + cbm_watcher_strategy_t strategy; + int poll_interval_ms; /* current adaptive cadence */ + int64_t last_scan_unix_s; /* wall clock of the last completed scan; 0 = none */ +} cbm_watcher_project_info_t; + +/* Return true when project_name is physically watched and fill *out with its + * published status; false (and *out zeroed) otherwise. Safe to call while the + * poll loop runs: the fields are published atomically by the poll path. */ +bool cbm_watcher_project_info(cbm_watcher_t *w, const char *project_name, + cbm_watcher_project_info_t *out); + /* Return the adaptive poll interval (ms) for a given file count. */ int cbm_watcher_poll_interval_ms(int file_count); diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index 3b97b3f65..1f77e1e58 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -1102,6 +1102,289 @@ TEST(daemon_application_reference_counts_one_shared_watch) { PASS(); } +/* #2167: index_status must say whether a project is kept fresh by the + * watcher. Only the MCP session's own project is watched; any other indexed + * project reports watched=false with the reason, instead of silently going + * stale (the reporter indexed via the CLI and waited for auto-sync). */ +static bool app_test_seed_project_db(const char *cache, const char *project, const char *root, + char *db_path, size_t db_path_size) { + snprintf(db_path, db_path_size, "%s/%s.db", cache, project); + cbm_store_t *seed = cbm_store_open_path(db_path); + bool seeded = seed && cbm_store_upsert_project(seed, project, root) == CBM_STORE_OK; + if (seed) { + cbm_store_close(seed); + } + return seeded; +} + +static void app_test_unlink_project_db(const char *db_path) { + char sidecar[APP_TEST_PATH_CAP]; + if (!db_path[0]) { + return; + } + (void)cbm_unlink(db_path); + snprintf(sidecar, sizeof(sidecar), "%s-wal", db_path); + (void)cbm_unlink(sidecar); + snprintf(sidecar, sizeof(sidecar), "%s-shm", db_path); + (void)cbm_unlink(sidecar); +} + +/* The tool result embeds the JSON document as escaped text: match + * \"key\":value inside it. */ +static bool app_test_response_has(const char *response, const char *key, const char *value) { + char needle[APP_TEST_PATH_CAP]; + snprintf(needle, sizeof(needle), "\\\"%s\\\":%s", key, value); + return response && strstr(response, needle) != NULL; +} + +static char *app_test_index_status(const cbm_daemon_runtime_application_callbacks_t *callbacks, + cbm_daemon_runtime_application_session_t *session, + const char *project) { + char args[APP_TEST_PATH_CAP]; + snprintf(args, sizeof(args), "{\"project\":\"%s\",\"format\":\"json\"}", project); + uint8_t *tool = NULL; + uint32_t tool_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + if (!app_test_tool_request("index_status", args, &tool, &tool_length) || + app_test_request(callbacks, session, tool, tool_length, &response, &response_length) != + CBM_DAEMON_RUNTIME_APPLICATION_OK) { + free(response); + response = NULL; + } + free(tool); + return (char *)response; +} + +TEST(daemon_application_index_status_reports_watch_issue2167) { + const char *old_cache = getenv("CBM_CACHE_DIR"); + bool had_cache = old_cache != NULL; + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + char root[APP_TEST_PATH_CAP]; + char other_root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-status-root-XXXXXX", cbm_tmpdir()); + snprintf(other_root, sizeof(other_root), "%s/cbm-app-status-other-XXXXXX", cbm_tmpdir()); + snprintf(cache, sizeof(cache), "%s/cbm-app-status-cache-XXXXXX", cbm_tmpdir()); + bool dirs_ok = + cbm_mkdtemp(root) != NULL && cbm_mkdtemp(other_root) != NULL && cbm_mkdtemp(cache) != NULL; + bool env_ok = (!had_cache || saved_cache) && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + char *project = dirs_ok ? cbm_project_name_from_path(root) : NULL; + char *other_project = dirs_ok ? cbm_project_name_from_path(other_root) : NULL; + char db_path[APP_TEST_PATH_CAP] = {0}; + char other_db_path[APP_TEST_PATH_CAP] = {0}; + bool seeded = project && other_project && + app_test_seed_project_db(cache, project, root, db_path, sizeof(db_path)) && + app_test_seed_project_db(cache, other_project, other_root, other_db_path, + sizeof(other_db_path)); + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL); + cbm_daemon_application_config_t config = {.watcher = watcher, .config = NULL}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 21); + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *ping = NULL; + uint32_t ping_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + /* Context + one MCP request registers the session root's watch. */ + bool registered = seeded && app_test_context_request(root, root, &context, &context_length) && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"ping\"}", + &ping, &ping_length) && + app_test_request(&callbacks, session, context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + registered = + registered && app_test_request(&callbacks, session, ping, ping_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + registered = registered && cbm_watcher_watch_count(watcher) == 1; + + char *own = registered ? app_test_index_status(&callbacks, session, project) : NULL; + char *other = registered ? app_test_index_status(&callbacks, session, other_project) : NULL; + bool own_watched = app_test_response_has(own, "watched", "true") && + app_test_response_has(own, "strategy", "\\\"pending\\\"") && + app_test_response_has(own, "poll_interval_ms", "5000"); + bool other_unwatched = app_test_response_has(other, "watched", "false") && + app_test_response_has(other, "reason", "\\\"not_session_project\\\""); + + callbacks.session_cancel(callbacks.context, session); + callbacks.session_close(callbacks.context, session); + cbm_daemon_application_free(application); + cbm_watcher_stop(watcher); + cbm_watcher_free(watcher); + cbm_store_close(store); + free(own); + free(other); + free(context); + free(ping); + free(project); + free(other_project); + app_test_unlink_project_db(db_path); + app_test_unlink_project_db(other_db_path); + (void)cbm_rmdir(root); + (void)cbm_rmdir(other_root); + (void)cbm_rmdir(cache); + if (saved_cache) { + (void)cbm_setenv("CBM_CACHE_DIR", saved_cache, 1); + } else if (!had_cache) { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + free(saved_cache); + + ASSERT_TRUE(env_ok); + ASSERT_TRUE(seeded); + ASSERT_TRUE(registered); + ASSERT_TRUE(own_watched); + ASSERT_TRUE(other_unwatched); + PASS(); +} + +/* #2167: watcher_enabled=false leaves the daemon without a watcher; the + * session's own project must then say so rather than look watched. */ +TEST(daemon_application_index_status_watcher_disabled_issue2167) { + const char *old_cache = getenv("CBM_CACHE_DIR"); + bool had_cache = old_cache != NULL; + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + char root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-nowatch-root-XXXXXX", cbm_tmpdir()); + snprintf(cache, sizeof(cache), "%s/cbm-app-nowatch-cache-XXXXXX", cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL; + bool env_ok = (!had_cache || saved_cache) && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + char *project = dirs_ok ? cbm_project_name_from_path(root) : NULL; + char db_path[APP_TEST_PATH_CAP] = {0}; + bool seeded = + project && app_test_seed_project_db(cache, project, root, db_path, sizeof(db_path)); + cbm_daemon_application_config_t config = {.watcher = NULL, .config = NULL}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 22); + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool context_ok = seeded && app_test_context_request(root, root, &context, &context_length) && + app_test_request(&callbacks, session, context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + char *status = context_ok ? app_test_index_status(&callbacks, session, project) : NULL; + bool disabled = app_test_response_has(status, "watched", "false") && + app_test_response_has(status, "reason", "\\\"watcher_disabled\\\""); + + callbacks.session_cancel(callbacks.context, session); + callbacks.session_close(callbacks.context, session); + cbm_daemon_application_free(application); + free(status); + free(context); + free(project); + app_test_unlink_project_db(db_path); + (void)cbm_rmdir(root); + (void)cbm_rmdir(cache); + if (saved_cache) { + (void)cbm_setenv("CBM_CACHE_DIR", saved_cache, 1); + } else if (!had_cache) { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + free(saved_cache); + + ASSERT_TRUE(env_ok); + ASSERT_TRUE(context_ok); + ASSERT_TRUE(disabled); + PASS(); +} + +/* #2167 with #1948: a non-git root that watch_non_git polls by file-tree scan + * reports strategy "tree". "none" stays reserved for a registered root that is + * never polled (a plain directory without the opt-in). */ +TEST(daemon_application_index_status_reports_tree_strategy_issue2167) { + const char *old_cache = getenv("CBM_CACHE_DIR"); + bool had_cache = old_cache != NULL; + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + char root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-tree-root-XXXXXX", cbm_tmpdir()); + snprintf(cache, sizeof(cache), "%s/cbm-app-tree-cache-XXXXXX", cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL; + bool env_ok = (!had_cache || saved_cache) && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + char *project = dirs_ok ? cbm_project_name_from_path(root) : NULL; + char db_path[APP_TEST_PATH_CAP] = {0}; + bool seeded = + project && app_test_seed_project_db(cache, project, root, db_path, sizeof(db_path)); + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL); + /* What the daemon host does at startup when watch_non_git is true. */ + cbm_watcher_set_poll_non_git(watcher, true); + cbm_daemon_application_config_t config = {.watcher = watcher, .config = NULL}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 23); + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *ping = NULL; + uint32_t ping_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + /* Context + one MCP request registers the session root's watch. */ + bool registered = seeded && app_test_context_request(root, root, &context, &context_length) && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"ping\"}", + &ping, &ping_length) && + app_test_request(&callbacks, session, context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + registered = + registered && app_test_request(&callbacks, session, ping, ping_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + registered = registered && cbm_watcher_watch_count(watcher) == 1; + /* The first poll completes the baseline: the plain directory gets tree + * polling and its strategy is published. */ + if (registered) { + (void)cbm_watcher_poll_once(watcher); + } + + char *status = registered ? app_test_index_status(&callbacks, session, project) : NULL; + bool tree = app_test_response_has(status, "watched", "true") && + app_test_response_has(status, "strategy", "\\\"tree\\\""); + + callbacks.session_cancel(callbacks.context, session); + callbacks.session_close(callbacks.context, session); + cbm_daemon_application_free(application); + cbm_watcher_stop(watcher); + cbm_watcher_free(watcher); + cbm_store_close(store); + free(status); + free(context); + free(ping); + free(project); + app_test_unlink_project_db(db_path); + (void)cbm_rmdir(root); + (void)cbm_rmdir(cache); + if (saved_cache) { + (void)cbm_setenv("CBM_CACHE_DIR", saved_cache, 1); + } else if (!had_cache) { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + free(saved_cache); + + ASSERT_TRUE(env_ok); + ASSERT_TRUE(seeded); + ASSERT_TRUE(registered); + ASSERT_TRUE(tree); + PASS(); +} + TEST(daemon_application_free_releases_live_watch_once) { const char *old_cache = getenv("CBM_CACHE_DIR"); bool had_cache = old_cache != NULL; @@ -6504,6 +6787,9 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_hook_context_preserves_event_and_dialect); RUN_TEST(daemon_application_mcp_notification_has_no_response); RUN_TEST(daemon_application_reference_counts_one_shared_watch); + RUN_TEST(daemon_application_index_status_reports_watch_issue2167); + RUN_TEST(daemon_application_index_status_watcher_disabled_issue2167); + RUN_TEST(daemon_application_index_status_reports_tree_strategy_issue2167); RUN_TEST(daemon_application_free_releases_live_watch_once); RUN_TEST(daemon_application_prune_clears_logical_watch_for_reregistration); RUN_TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions); diff --git a/tests/test_watcher.c b/tests/test_watcher.c index 91b4568c9..20a6cad34 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -2181,6 +2181,44 @@ TEST(watcher_index_failure_count_unknown_project) { PASS(); } +/* #2167: index_status reads the watch state through cbm_watcher_project_info. + * A registered project starts "pending" at the base cadence with no scan; + * the first poll completes its baseline and publishes the strategy plus a + * wall-clock scan time. Unwatched projects report false with *out zeroed. */ +TEST(watcher_project_info_publishes_scan_issue2167) { + char root[256]; + snprintf(root, sizeof(root), "%s/cbm_watcher_info_XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(root)); + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + + cbm_watcher_project_info_t info; + ASSERT_FALSE(cbm_watcher_project_info(w, "never-watched", &info)); + ASSERT_EQ(info.poll_interval_ms, 0); + ASSERT_FALSE(cbm_watcher_project_info(NULL, "x", &info)); + + ASSERT_TRUE(cbm_watcher_watch(w, "info-proj", root)); + ASSERT_TRUE(cbm_watcher_project_info(w, "info-proj", &info)); + ASSERT_EQ(info.strategy, CBM_WATCHER_STRATEGY_PENDING); + ASSERT_EQ(info.poll_interval_ms, cbm_watcher_poll_interval_ms(0)); + ASSERT_EQ(info.last_scan_unix_s, 0); + + /* A plain directory (no git): the baseline decides it is never polled. */ + int64_t before = (int64_t)time(NULL); + (void)cbm_watcher_poll_once(w); + ASSERT_TRUE(cbm_watcher_project_info(w, "info-proj", &info)); + ASSERT_EQ(info.strategy, CBM_WATCHER_STRATEGY_NONE); + ASSERT_TRUE(info.last_scan_unix_s >= before); + + cbm_watcher_unwatch(w, "info-proj"); + ASSERT_FALSE(cbm_watcher_project_info(w, "info-proj", &info)); + + cbm_watcher_free(w); + cbm_store_close(store); + (void)cbm_rmdir(root); + PASS(); +} + TEST(watcher_multiple_projects) { /* Create two temporary git repos */ char tmpdirA[256]; @@ -3872,6 +3910,7 @@ SUITE(watcher) { RUN_TEST(watcher_index_failure_backoff_gates_repolling_issue2015); RUN_TEST(watcher_sustained_failure_logs_once_issue2015); RUN_TEST(watcher_index_failure_count_unknown_project); + RUN_TEST(watcher_project_info_publishes_scan_issue2167); /* Lifecycle */ RUN_TEST(watcher_create_free);