nexo-brain 7.20.1 → 7.20.3
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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/doctor/providers/runtime.py +61 -0
- package/src/local_context/__init__.py +2 -0
- package/src/local_context/api.py +408 -41
- package/src/local_context/util.py +9 -1
- package/src/scripts/nexo-local-index.py +11 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.20.
|
|
3
|
+
"version": "7.20.3",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/README.md
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
[Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
|
|
20
20
|
|
|
21
|
-
Version `7.20.
|
|
21
|
+
Version `7.20.2` is the current packaged-runtime line. Patch release over v7.20.1 — Local Context now requeues stalled work, reports real macOS/Windows background-service health, records scan errors and preserves Windows drive roots.
|
|
22
|
+
|
|
23
|
+
Previously in `7.20.1`: patch release over v7.20.0 — the Local Context service now recovers from orphaned locks and mixed-version cycle failures instead of leaving the background index stuck.
|
|
22
24
|
|
|
23
25
|
Previously in `7.20.0`: minor release over v7.19.0 — the Local Context index now reconciles known files and folders on every service cycle, so created, modified, deleted and newly excluded local files are reflected automatically between full scans.
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.20.
|
|
3
|
+
"version": "7.20.3",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
|
|
6
6
|
"homepage": "https://nexo-brain.com",
|
|
@@ -3833,6 +3833,66 @@ def check_automation_caller_coverage(days: int = 7) -> DoctorCheck:
|
|
|
3833
3833
|
)
|
|
3834
3834
|
|
|
3835
3835
|
|
|
3836
|
+
def check_local_index_hygiene(fix: bool = False) -> DoctorCheck:
|
|
3837
|
+
try:
|
|
3838
|
+
from local_context import api as local_context_api
|
|
3839
|
+
|
|
3840
|
+
result = local_context_api.local_index_hygiene(fix=fix)
|
|
3841
|
+
residue = result.get("residue") or {}
|
|
3842
|
+
cleanup = result.get("cleanup") or {}
|
|
3843
|
+
suspect_roots = [str(path) for path in result.get("removed_roots") or []]
|
|
3844
|
+
residue_total = sum(int(residue.get(key, 0) or 0) for key in ("assets", "jobs", "errors", "dirs", "checkpoints"))
|
|
3845
|
+
cleanup_total = sum(int(cleanup.get(key, 0) or 0) for key in ("assets", "jobs", "errors", "dirs", "checkpoints"))
|
|
3846
|
+
evidence = [
|
|
3847
|
+
"suspect_installer_roots=" + str(len(suspect_roots)),
|
|
3848
|
+
"residue=" + json.dumps(residue, sort_keys=True),
|
|
3849
|
+
"cleanup=" + json.dumps(cleanup, sort_keys=True),
|
|
3850
|
+
]
|
|
3851
|
+
evidence.extend(f"root={path}" for path in suspect_roots[:5])
|
|
3852
|
+
if residue_total == 0 and not suspect_roots:
|
|
3853
|
+
return DoctorCheck(
|
|
3854
|
+
id="runtime.local_index_hygiene",
|
|
3855
|
+
tier="runtime",
|
|
3856
|
+
status="healthy",
|
|
3857
|
+
severity="info",
|
|
3858
|
+
summary="Local memory index hygiene is clean",
|
|
3859
|
+
evidence=evidence,
|
|
3860
|
+
repair_plan=[],
|
|
3861
|
+
)
|
|
3862
|
+
if fix:
|
|
3863
|
+
return DoctorCheck(
|
|
3864
|
+
id="runtime.local_index_hygiene",
|
|
3865
|
+
tier="runtime",
|
|
3866
|
+
status="healthy",
|
|
3867
|
+
severity="info",
|
|
3868
|
+
summary="Local memory index hygiene repaired",
|
|
3869
|
+
evidence=evidence,
|
|
3870
|
+
repair_plan=[],
|
|
3871
|
+
fixed=cleanup_total > 0 or bool(suspect_roots),
|
|
3872
|
+
)
|
|
3873
|
+
return DoctorCheck(
|
|
3874
|
+
id="runtime.local_index_hygiene",
|
|
3875
|
+
tier="runtime",
|
|
3876
|
+
status="degraded",
|
|
3877
|
+
severity="warn",
|
|
3878
|
+
summary="Local memory index has stale removed-root residue",
|
|
3879
|
+
evidence=evidence,
|
|
3880
|
+
repair_plan=["Run `nexo doctor --tier runtime --fix` to purge stale local memory roots and installer-volume residue"],
|
|
3881
|
+
escalation_prompt="Local memory status may show stale pending or failed jobs from removed roots.",
|
|
3882
|
+
)
|
|
3883
|
+
except Exception as exc:
|
|
3884
|
+
return DoctorCheck(
|
|
3885
|
+
id="runtime.local_index_hygiene",
|
|
3886
|
+
tier="runtime",
|
|
3887
|
+
status="degraded",
|
|
3888
|
+
severity="warn",
|
|
3889
|
+
summary="Local memory index hygiene could not be checked",
|
|
3890
|
+
evidence=[str(exc)],
|
|
3891
|
+
repair_plan=["Inspect local_context.api.local_index_hygiene and runtime DB tables"],
|
|
3892
|
+
escalation_prompt="Support cannot verify local memory index residue.",
|
|
3893
|
+
)
|
|
3894
|
+
|
|
3895
|
+
|
|
3836
3896
|
def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
|
|
3837
3897
|
"""Run all runtime-tier checks. Read-only by default."""
|
|
3838
3898
|
return [
|
|
@@ -3854,6 +3914,7 @@ def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
|
|
|
3854
3914
|
safe_check(check_automation_telemetry),
|
|
3855
3915
|
safe_check(check_automation_caller_coverage),
|
|
3856
3916
|
safe_check(check_state_watchers),
|
|
3917
|
+
safe_check(check_local_index_hygiene, fix=fix),
|
|
3857
3918
|
safe_check(check_release_artifact_sync),
|
|
3858
3919
|
safe_check(check_release_trace_hygiene),
|
|
3859
3920
|
safe_check(check_launchagent_inventory),
|
|
@@ -16,6 +16,7 @@ from .api import (
|
|
|
16
16
|
get_neighbors,
|
|
17
17
|
list_exclusions,
|
|
18
18
|
list_roots,
|
|
19
|
+
local_index_hygiene,
|
|
19
20
|
model_status,
|
|
20
21
|
pause,
|
|
21
22
|
purge_asset,
|
|
@@ -39,6 +40,7 @@ __all__ = [
|
|
|
39
40
|
"get_neighbors",
|
|
40
41
|
"list_exclusions",
|
|
41
42
|
"list_roots",
|
|
43
|
+
"local_index_hygiene",
|
|
42
44
|
"model_status",
|
|
43
45
|
"pause",
|
|
44
46
|
"purge_asset",
|
package/src/local_context/api.py
CHANGED
|
@@ -64,9 +64,10 @@ def remove_root(path: str) -> dict:
|
|
|
64
64
|
conn = _conn()
|
|
65
65
|
root_path = norm_path(path)
|
|
66
66
|
conn.execute("UPDATE local_index_roots SET status='removed', updated_at=? WHERE root_path=?", (now(), root_path))
|
|
67
|
+
cleanup = _purge_removed_root_payloads(conn, root_paths=[root_path])
|
|
67
68
|
conn.commit()
|
|
68
|
-
log_event("info", "root_removed", "Root removed", path=redact_path(root_path))
|
|
69
|
-
return {"ok": True, "root_path": root_path}
|
|
69
|
+
log_event("info", "root_removed", "Root removed", path=redact_path(root_path), cleanup=cleanup)
|
|
70
|
+
return {"ok": True, "root_path": root_path, "cleanup": cleanup}
|
|
70
71
|
|
|
71
72
|
|
|
72
73
|
def list_roots() -> list[dict]:
|
|
@@ -108,6 +109,8 @@ def _mounted_volume_roots() -> list[str]:
|
|
|
108
109
|
try:
|
|
109
110
|
if candidate.name.startswith(".") or not candidate.is_dir():
|
|
110
111
|
continue
|
|
112
|
+
if _should_skip_mounted_root(candidate):
|
|
113
|
+
continue
|
|
111
114
|
resolved = candidate.resolve()
|
|
112
115
|
if resolved == root_resolved:
|
|
113
116
|
continue
|
|
@@ -137,6 +140,124 @@ def ensure_default_roots() -> dict:
|
|
|
137
140
|
return {"ok": True, "created": len(created), "roots": list_roots()}
|
|
138
141
|
|
|
139
142
|
|
|
143
|
+
def _should_skip_mounted_root(candidate: Path) -> bool:
|
|
144
|
+
name = candidate.name.strip().lower()
|
|
145
|
+
if name in {"nexo desktop", "nexo desktop beta"} or name.startswith("nexo desktop "):
|
|
146
|
+
return True
|
|
147
|
+
try:
|
|
148
|
+
app_bundles = [child.name.lower() for child in candidate.iterdir() if child.suffix.lower() == ".app"]
|
|
149
|
+
except Exception:
|
|
150
|
+
app_bundles = []
|
|
151
|
+
if any(name.startswith("nexo desktop") for name in app_bundles):
|
|
152
|
+
installer_markers = (
|
|
153
|
+
candidate / ".background",
|
|
154
|
+
candidate / "Applications",
|
|
155
|
+
candidate / ".DS_Store",
|
|
156
|
+
)
|
|
157
|
+
if any(marker.exists() for marker in installer_markers):
|
|
158
|
+
return True
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _removed_root_filters(conn, *, root_paths: list[str] | None = None) -> tuple[list[int], list[str]]:
|
|
163
|
+
if root_paths:
|
|
164
|
+
placeholders = ",".join("?" for _ in root_paths)
|
|
165
|
+
rows = conn.execute(
|
|
166
|
+
f"SELECT id, root_path FROM local_index_roots WHERE root_path IN ({placeholders}) AND status='removed'",
|
|
167
|
+
tuple(root_paths),
|
|
168
|
+
).fetchall()
|
|
169
|
+
else:
|
|
170
|
+
rows = conn.execute("SELECT id, root_path FROM local_index_roots WHERE status='removed'").fetchall()
|
|
171
|
+
return [int(row["id"]) for row in rows], [str(row["root_path"]) for row in rows]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _removed_root_payload_counts(conn, *, root_paths: list[str] | None = None) -> dict:
|
|
175
|
+
root_ids, removed_paths = _removed_root_filters(conn, root_paths=root_paths)
|
|
176
|
+
if not root_ids and not removed_paths:
|
|
177
|
+
return {"assets": 0, "jobs": 0, "errors": 0, "dirs": 0, "checkpoints": 0}
|
|
178
|
+
asset_filter, params = _removed_root_asset_filter(root_ids, removed_paths)
|
|
179
|
+
if not asset_filter:
|
|
180
|
+
return {"assets": 0, "jobs": 0, "errors": 0, "dirs": 0, "checkpoints": 0}
|
|
181
|
+
asset_subquery = f"SELECT asset_id FROM local_assets WHERE {asset_filter}"
|
|
182
|
+
assets = int(conn.execute(f"SELECT COUNT(*) AS total FROM local_assets WHERE {asset_filter}", tuple(params)).fetchone()["total"] or 0)
|
|
183
|
+
jobs = int(conn.execute(f"SELECT COUNT(*) AS total FROM local_index_jobs WHERE asset_id IN ({asset_subquery})", tuple(params)).fetchone()["total"] or 0)
|
|
184
|
+
errors = int(conn.execute(f"SELECT COUNT(*) AS total FROM local_index_errors WHERE asset_id IN ({asset_subquery})", tuple(params)).fetchone()["total"] or 0)
|
|
185
|
+
for path in removed_paths:
|
|
186
|
+
errors += int(conn.execute("SELECT COUNT(*) AS total FROM local_index_errors WHERE asset_id='' AND (path = ? OR path LIKE ?)", (path, f"{path}/%")).fetchone()["total"] or 0)
|
|
187
|
+
dirs = 0
|
|
188
|
+
checkpoints = 0
|
|
189
|
+
if root_ids:
|
|
190
|
+
root_placeholders = ",".join("?" for _ in root_ids)
|
|
191
|
+
dirs = int(conn.execute(f"SELECT COUNT(*) AS total FROM local_index_dirs WHERE root_id IN ({root_placeholders})", tuple(root_ids)).fetchone()["total"] or 0)
|
|
192
|
+
checkpoints = int(conn.execute(f"SELECT COUNT(*) AS total FROM local_index_checkpoints WHERE root_id IN ({root_placeholders})", tuple(root_ids)).fetchone()["total"] or 0)
|
|
193
|
+
return {"assets": assets, "jobs": jobs, "errors": errors, "dirs": dirs, "checkpoints": checkpoints}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _removed_root_asset_filter(root_ids: list[int], removed_paths: list[str]) -> tuple[str, list[Any]]:
|
|
197
|
+
filters: list[str] = []
|
|
198
|
+
params: list[Any] = []
|
|
199
|
+
if root_ids:
|
|
200
|
+
root_placeholders = ",".join("?" for _ in root_ids)
|
|
201
|
+
filters.append(f"root_id IN ({root_placeholders})")
|
|
202
|
+
params.extend(root_ids)
|
|
203
|
+
for path in removed_paths:
|
|
204
|
+
filters.append("(path = ? OR path LIKE ?)")
|
|
205
|
+
params.extend([path, f"{path}/%"])
|
|
206
|
+
return " OR ".join(filters), params
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _purge_removed_root_payloads(conn, *, root_paths: list[str] | None = None) -> dict:
|
|
210
|
+
root_ids, removed_paths = _removed_root_filters(conn, root_paths=root_paths)
|
|
211
|
+
if not root_ids and not removed_paths:
|
|
212
|
+
return {"assets": 0, "jobs": 0, "errors": 0, "dirs": 0, "checkpoints": 0}
|
|
213
|
+
|
|
214
|
+
asset_filter, params = _removed_root_asset_filter(root_ids, removed_paths)
|
|
215
|
+
if not asset_filter:
|
|
216
|
+
return {"assets": 0, "jobs": 0, "errors": 0, "dirs": 0, "checkpoints": 0}
|
|
217
|
+
asset_subquery = f"SELECT asset_id FROM local_assets WHERE {asset_filter}"
|
|
218
|
+
counts = _removed_root_payload_counts(conn, root_paths=root_paths)
|
|
219
|
+
|
|
220
|
+
for table in ("local_embeddings", "local_chunks", "local_entities", "local_asset_versions"):
|
|
221
|
+
conn.execute(f"DELETE FROM {table} WHERE asset_id IN ({asset_subquery})", tuple(params))
|
|
222
|
+
conn.execute(f"DELETE FROM local_relations WHERE source_asset_id IN ({asset_subquery})", tuple(params))
|
|
223
|
+
conn.execute(f"DELETE FROM local_relations WHERE target_ref IN ({asset_subquery})", tuple(params))
|
|
224
|
+
conn.execute(f"DELETE FROM local_index_jobs WHERE asset_id IN ({asset_subquery})", tuple(params))
|
|
225
|
+
conn.execute(f"DELETE FROM local_index_errors WHERE asset_id IN ({asset_subquery})", tuple(params))
|
|
226
|
+
|
|
227
|
+
for path in removed_paths:
|
|
228
|
+
conn.execute("DELETE FROM local_index_errors WHERE path = ? OR path LIKE ?", (path, f"{path}/%"))
|
|
229
|
+
|
|
230
|
+
if root_ids:
|
|
231
|
+
root_placeholders = ",".join("?" for _ in root_ids)
|
|
232
|
+
conn.execute(f"DELETE FROM local_index_dirs WHERE root_id IN ({root_placeholders})", tuple(root_ids))
|
|
233
|
+
conn.execute(f"DELETE FROM local_index_checkpoints WHERE root_id IN ({root_placeholders})", tuple(root_ids))
|
|
234
|
+
conn.execute(f"DELETE FROM local_assets WHERE {asset_filter}", tuple(params))
|
|
235
|
+
return counts
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def local_index_hygiene(*, fix: bool = False) -> dict:
|
|
239
|
+
conn = _conn()
|
|
240
|
+
removed_paths: list[str] = []
|
|
241
|
+
for row in conn.execute("SELECT id, root_path FROM local_index_roots").fetchall():
|
|
242
|
+
path = str(row["root_path"] or "")
|
|
243
|
+
if _should_skip_mounted_root(Path(path)):
|
|
244
|
+
removed_paths.append(path)
|
|
245
|
+
if fix:
|
|
246
|
+
conn.execute("UPDATE local_index_roots SET status='removed', updated_at=? WHERE id=?", (now(), row["id"]))
|
|
247
|
+
before = _removed_root_payload_counts(conn)
|
|
248
|
+
cleanup = {"assets": 0, "jobs": 0, "errors": 0, "dirs": 0, "checkpoints": 0}
|
|
249
|
+
if fix:
|
|
250
|
+
cleanup = _purge_removed_root_payloads(conn)
|
|
251
|
+
conn.commit()
|
|
252
|
+
if fix and (removed_paths or any(int(cleanup.get(key, 0) or 0) for key in ("assets", "jobs", "errors", "dirs", "checkpoints"))):
|
|
253
|
+
log_event("info", "index_hygiene_repaired", "Local memory index hygiene repaired", roots=[redact_path(path) for path in removed_paths], cleanup=cleanup)
|
|
254
|
+
return {"ok": True, "fix": fix, "removed_roots": removed_paths, "residue": before, "cleanup": cleanup}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def repair_index_hygiene() -> dict:
|
|
258
|
+
return local_index_hygiene(fix=True)
|
|
259
|
+
|
|
260
|
+
|
|
140
261
|
def add_exclusion(path: str, *, reason: str = "user") -> dict:
|
|
141
262
|
conn = _conn()
|
|
142
263
|
excluded_path = norm_path(path)
|
|
@@ -398,7 +519,7 @@ def _mark_asset_deleted(conn, asset_id: str, deleted_at: float | None = None) ->
|
|
|
398
519
|
"""
|
|
399
520
|
UPDATE local_index_jobs
|
|
400
521
|
SET status='done', last_error_code='asset_deleted', updated_at=?
|
|
401
|
-
WHERE asset_id=? AND status IN ('pending', 'running')
|
|
522
|
+
WHERE asset_id=? AND status IN ('pending', 'running', 'failed')
|
|
402
523
|
""",
|
|
403
524
|
(deleted_at, asset_id),
|
|
404
525
|
)
|
|
@@ -425,6 +546,48 @@ def _mark_dir_subtree_deleted(conn, dir_path: str, deleted_at: float | None = No
|
|
|
425
546
|
return len(rows)
|
|
426
547
|
|
|
427
548
|
|
|
549
|
+
def _record_index_error(
|
|
550
|
+
conn,
|
|
551
|
+
*,
|
|
552
|
+
asset_id: str = "",
|
|
553
|
+
path: str = "",
|
|
554
|
+
phase: str,
|
|
555
|
+
error_code: str,
|
|
556
|
+
user_message: str,
|
|
557
|
+
technical_detail: str,
|
|
558
|
+
retryable: bool = True,
|
|
559
|
+
) -> None:
|
|
560
|
+
conn.execute(
|
|
561
|
+
"""
|
|
562
|
+
INSERT INTO local_index_errors(asset_id, path, phase, error_code, user_message, technical_detail, retryable, created_at)
|
|
563
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
564
|
+
""",
|
|
565
|
+
(asset_id, path, phase, error_code, user_message, technical_detail, 1 if retryable else 0, now()),
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _record_scan_error(conn, stats: dict | None, path: str, phase: str, exc: Exception) -> None:
|
|
570
|
+
if stats is not None:
|
|
571
|
+
stats["errors"] = int(stats.get("errors", 0) or 0) + 1
|
|
572
|
+
logged = int(stats.get("_errors_logged", 0) or 0)
|
|
573
|
+
if logged >= 20:
|
|
574
|
+
return
|
|
575
|
+
stats["_errors_logged"] = logged + 1
|
|
576
|
+
_record_index_error(
|
|
577
|
+
conn,
|
|
578
|
+
path=path,
|
|
579
|
+
phase=phase,
|
|
580
|
+
error_code=type(exc).__name__,
|
|
581
|
+
user_message="Algunas carpetas o archivos no se pudieron leer",
|
|
582
|
+
technical_detail=str(exc),
|
|
583
|
+
retryable=True,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _public_stats(stats: dict) -> dict:
|
|
588
|
+
return {key: value for key, value in stats.items() if not str(key).startswith("_")}
|
|
589
|
+
|
|
590
|
+
|
|
428
591
|
def enqueue_job(conn, asset_id: str, job_type: str, *, priority: int = 50) -> str:
|
|
429
592
|
job_id = stable_id("job", f"{asset_id}:{job_type}")
|
|
430
593
|
conn.execute(
|
|
@@ -447,6 +610,7 @@ def _iter_files(
|
|
|
447
610
|
limit: int | None = None,
|
|
448
611
|
start_after: str = "",
|
|
449
612
|
seen_at: float | None = None,
|
|
613
|
+
stats: dict | None = None,
|
|
450
614
|
):
|
|
451
615
|
seen_at = seen_at or now()
|
|
452
616
|
seen_dirs: set[tuple[int, int]] = set()
|
|
@@ -461,7 +625,8 @@ def _iter_files(
|
|
|
461
625
|
continue
|
|
462
626
|
try:
|
|
463
627
|
st = current.stat()
|
|
464
|
-
except Exception:
|
|
628
|
+
except Exception as exc:
|
|
629
|
+
_record_scan_error(conn, stats, str(current), "quick_index", exc)
|
|
465
630
|
continue
|
|
466
631
|
key = (getattr(st, "st_dev", 0), getattr(st, "st_ino", 0))
|
|
467
632
|
if key in seen_dirs:
|
|
@@ -470,7 +635,8 @@ def _iter_files(
|
|
|
470
635
|
_upsert_dir(conn, root_id, current, seen_at, st)
|
|
471
636
|
try:
|
|
472
637
|
entries = sorted(current.iterdir(), key=lambda item: str(item).lower())
|
|
473
|
-
except Exception:
|
|
638
|
+
except Exception as exc:
|
|
639
|
+
_record_scan_error(conn, stats, str(current), "quick_index", exc)
|
|
474
640
|
continue
|
|
475
641
|
dirs: list[Path] = []
|
|
476
642
|
for entry in entries:
|
|
@@ -577,8 +743,8 @@ def _reconcile_known_assets(conn, exclusions: list[str], *, limit: int) -> dict:
|
|
|
577
743
|
continue
|
|
578
744
|
st = file_path.stat()
|
|
579
745
|
fingerprint = quick_fingerprint(file_path, st)
|
|
580
|
-
except Exception:
|
|
581
|
-
stats
|
|
746
|
+
except Exception as exc:
|
|
747
|
+
_record_scan_error(conn, stats, path, "live_reconcile", exc)
|
|
582
748
|
continue
|
|
583
749
|
if fingerprint != row["quick_fingerprint"]:
|
|
584
750
|
_, changed, state = _upsert_asset(conn, int(row["root_id"] or 0), file_path, seen_at, int(row["depth"] or 2))
|
|
@@ -647,8 +813,8 @@ def _scan_known_directory(
|
|
|
647
813
|
if not current.is_dir():
|
|
648
814
|
continue
|
|
649
815
|
entries = sorted(current.iterdir(), key=lambda item: str(item).lower())
|
|
650
|
-
except Exception:
|
|
651
|
-
stats
|
|
816
|
+
except Exception as exc:
|
|
817
|
+
_record_scan_error(conn, stats, str(current), "live_reconcile", exc)
|
|
652
818
|
continue
|
|
653
819
|
scanned_dirs += 1
|
|
654
820
|
stats["dirs_scanned"] += 1
|
|
@@ -679,8 +845,8 @@ def _scan_known_directory(
|
|
|
679
845
|
stats["files_changed"] += 1
|
|
680
846
|
if state != "ok":
|
|
681
847
|
stats["errors"] += 1
|
|
682
|
-
except Exception:
|
|
683
|
-
stats
|
|
848
|
+
except Exception as exc:
|
|
849
|
+
_record_scan_error(conn, stats, str(entry), "live_reconcile", exc)
|
|
684
850
|
deleted_files, deleted_dirs = _prune_missing_children(conn, current, seen_files, seen_dirs, seen_at)
|
|
685
851
|
stats["files_deleted"] += deleted_files
|
|
686
852
|
stats["dirs_deleted"] += deleted_dirs
|
|
@@ -731,8 +897,8 @@ def _reconcile_known_dirs(conn, exclusions: list[str], *, dir_limit: int, file_l
|
|
|
731
897
|
continue
|
|
732
898
|
st = dir_path.stat()
|
|
733
899
|
fingerprint = _dir_fingerprint(dir_path, st)
|
|
734
|
-
except Exception:
|
|
735
|
-
stats
|
|
900
|
+
except Exception as exc:
|
|
901
|
+
_record_scan_error(conn, stats, str(dir_path), "live_reconcile", exc)
|
|
736
902
|
continue
|
|
737
903
|
if fingerprint != row["quick_fingerprint"]:
|
|
738
904
|
stats["changed"] += 1
|
|
@@ -773,9 +939,18 @@ def reconcile_live_changes(
|
|
|
773
939
|
+ int(dir_stats.get("dirs_deleted", 0))
|
|
774
940
|
+ int(dir_stats.get("excluded_dirs", 0))
|
|
775
941
|
)
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
942
|
+
error_total = int(asset_stats.get("errors", 0) or 0) + int(dir_stats.get("errors", 0) or 0)
|
|
943
|
+
public_asset_stats = _public_stats(asset_stats)
|
|
944
|
+
public_dir_stats = _public_stats(dir_stats)
|
|
945
|
+
if changed_total or error_total:
|
|
946
|
+
log_event(
|
|
947
|
+
"warn" if error_total else "info",
|
|
948
|
+
"live_reconcile_finished",
|
|
949
|
+
"Local memory live changes reconciled",
|
|
950
|
+
assets=public_asset_stats,
|
|
951
|
+
dirs=public_dir_stats,
|
|
952
|
+
)
|
|
953
|
+
return {"ok": True, "assets": public_asset_stats, "dirs": public_dir_stats}
|
|
779
954
|
|
|
780
955
|
|
|
781
956
|
def scan_once(*, limit: int | None = None) -> dict:
|
|
@@ -814,6 +989,7 @@ def scan_once(*, limit: int | None = None) -> dict:
|
|
|
814
989
|
limit=limit,
|
|
815
990
|
start_after=str(checkpoint["current_path"] or ""),
|
|
816
991
|
seen_at=cycle_started_at,
|
|
992
|
+
stats=totals,
|
|
817
993
|
):
|
|
818
994
|
asset_id, changed, state = _upsert_asset(conn, root_id, file_path, cycle_started_at, int(root["depth"] or 2))
|
|
819
995
|
last_seen_path = norm_path(file_path)
|
|
@@ -833,7 +1009,7 @@ def scan_once(*, limit: int | None = None) -> dict:
|
|
|
833
1009
|
path=redact_path(str(root_path)),
|
|
834
1010
|
)
|
|
835
1011
|
if last_seen_path:
|
|
836
|
-
_save_checkpoint(conn, root_id, last_seen_path, cycle_started_at=cycle_started_at, totals=totals)
|
|
1012
|
+
_save_checkpoint(conn, root_id, last_seen_path, cycle_started_at=cycle_started_at, totals=_public_stats(totals))
|
|
837
1013
|
else:
|
|
838
1014
|
rows = conn.execute(
|
|
839
1015
|
"SELECT asset_id FROM local_assets WHERE root_id=? AND status='active' AND last_seen_at < ?",
|
|
@@ -850,8 +1026,9 @@ def scan_once(*, limit: int | None = None) -> dict:
|
|
|
850
1026
|
(now(), now(), root_id),
|
|
851
1027
|
)
|
|
852
1028
|
conn.commit()
|
|
853
|
-
|
|
854
|
-
|
|
1029
|
+
public_totals = _public_stats(totals)
|
|
1030
|
+
log_event("warn" if public_totals.get("errors") else "info", "scan_finished", "Local memory scan finished", **public_totals)
|
|
1031
|
+
return {"ok": True, **public_totals}
|
|
855
1032
|
|
|
856
1033
|
|
|
857
1034
|
def _latest_version_id(conn, asset_id: str) -> str:
|
|
@@ -913,11 +1090,35 @@ def _replace_entities(conn, asset_id: str, version_id: str, values: list[str]) -
|
|
|
913
1090
|
)
|
|
914
1091
|
|
|
915
1092
|
|
|
1093
|
+
def _requeue_due_jobs(conn) -> dict:
|
|
1094
|
+
current = now()
|
|
1095
|
+
failed = conn.execute(
|
|
1096
|
+
"""
|
|
1097
|
+
UPDATE local_index_jobs
|
|
1098
|
+
SET status='pending', claimed_by='', lease_expires_at=NULL, updated_at=?
|
|
1099
|
+
WHERE status='failed' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
|
1100
|
+
""",
|
|
1101
|
+
(current, current),
|
|
1102
|
+
).rowcount
|
|
1103
|
+
expired = conn.execute(
|
|
1104
|
+
"""
|
|
1105
|
+
UPDATE local_index_jobs
|
|
1106
|
+
SET status='pending', claimed_by='', lease_expires_at=NULL, updated_at=?
|
|
1107
|
+
WHERE status='running' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?
|
|
1108
|
+
""",
|
|
1109
|
+
(current, current),
|
|
1110
|
+
).rowcount
|
|
1111
|
+
if failed or expired:
|
|
1112
|
+
log_event("warn", "jobs_requeued", "Local memory recovered stalled jobs", failed=failed, expired=expired)
|
|
1113
|
+
return {"failed": int(failed or 0), "expired": int(expired or 0)}
|
|
1114
|
+
|
|
1115
|
+
|
|
916
1116
|
def process_jobs(*, limit: int = 100) -> dict:
|
|
917
1117
|
conn = _conn()
|
|
918
1118
|
if _is_paused():
|
|
919
1119
|
log_event("info", "jobs_skipped_paused", "Local memory jobs skipped because indexing is paused")
|
|
920
1120
|
return {"ok": True, "paused": True, "processed": 0, "failed": 0}
|
|
1121
|
+
recovered = _requeue_due_jobs(conn)
|
|
921
1122
|
rows = conn.execute(
|
|
922
1123
|
"""
|
|
923
1124
|
SELECT j.*, a.path, a.depth, a.status AS asset_status
|
|
@@ -976,17 +1177,20 @@ def process_jobs(*, limit: int = 100) -> dict:
|
|
|
976
1177
|
""",
|
|
977
1178
|
(now() + 3600, type(exc).__name__, now(), job_id),
|
|
978
1179
|
)
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1180
|
+
_record_index_error(
|
|
1181
|
+
conn,
|
|
1182
|
+
asset_id=asset_id,
|
|
1183
|
+
path=row["path"],
|
|
1184
|
+
phase=job_type,
|
|
1185
|
+
error_code=type(exc).__name__,
|
|
1186
|
+
user_message="Algunos archivos no se pudieron leer",
|
|
1187
|
+
technical_detail=str(exc),
|
|
1188
|
+
retryable=True,
|
|
985
1189
|
)
|
|
986
1190
|
conn.commit()
|
|
987
1191
|
if processed or failed:
|
|
988
1192
|
log_event("info", "jobs_processed", "Local memory jobs processed", processed=processed, failed=failed)
|
|
989
|
-
return {"ok": True, "processed": processed, "failed": failed}
|
|
1193
|
+
return {"ok": True, "processed": processed, "failed": failed, "recovered": recovered}
|
|
990
1194
|
|
|
991
1195
|
|
|
992
1196
|
def run_once(
|
|
@@ -1000,6 +1204,12 @@ def run_once(
|
|
|
1000
1204
|
) -> dict:
|
|
1001
1205
|
if root:
|
|
1002
1206
|
add_root(root)
|
|
1207
|
+
elif (
|
|
1208
|
+
os.environ.get("NEXO_LOCAL_INDEX_DISABLE_DEFAULT_ROOTS", "").strip() != "1"
|
|
1209
|
+
and os.environ.get("NEXO_SKIP_FS_INDEX", "").strip() != "1"
|
|
1210
|
+
and not list_roots()
|
|
1211
|
+
):
|
|
1212
|
+
ensure_default_roots()
|
|
1003
1213
|
live_result = reconcile_live_changes(
|
|
1004
1214
|
asset_limit=live_asset_limit,
|
|
1005
1215
|
dir_limit=live_dir_limit,
|
|
@@ -1013,9 +1223,19 @@ def run_once(
|
|
|
1013
1223
|
def _problem_rows(conn) -> list[dict]:
|
|
1014
1224
|
rows = conn.execute(
|
|
1015
1225
|
"""
|
|
1016
|
-
SELECT path, phase, error_code, user_message, technical_detail, retryable, created_at
|
|
1017
|
-
FROM local_index_errors
|
|
1018
|
-
|
|
1226
|
+
SELECT e.path, e.phase, e.error_code, e.user_message, e.technical_detail, e.retryable, e.created_at
|
|
1227
|
+
FROM local_index_errors e
|
|
1228
|
+
LEFT JOIN local_assets a ON a.asset_id=e.asset_id
|
|
1229
|
+
LEFT JOIN local_index_roots r ON r.id=a.root_id
|
|
1230
|
+
WHERE COALESCE(r.status, 'active') != 'removed'
|
|
1231
|
+
AND NOT EXISTS (
|
|
1232
|
+
SELECT 1
|
|
1233
|
+
FROM local_index_roots rr
|
|
1234
|
+
WHERE rr.status='removed'
|
|
1235
|
+
AND e.path != ''
|
|
1236
|
+
AND (e.path = rr.root_path OR e.path LIKE rr.root_path || '/%')
|
|
1237
|
+
)
|
|
1238
|
+
ORDER BY e.id DESC
|
|
1019
1239
|
LIMIT 20
|
|
1020
1240
|
"""
|
|
1021
1241
|
).fetchall()
|
|
@@ -1040,7 +1260,7 @@ def _problem_rows(conn) -> list[dict]:
|
|
|
1040
1260
|
"""
|
|
1041
1261
|
SELECT created_at, level, event, message, metadata_json
|
|
1042
1262
|
FROM local_index_logs
|
|
1043
|
-
WHERE event IN ('service_cycle_failed', 'service_cycle_compat_fallback')
|
|
1263
|
+
WHERE event IN ('service_cycle_failed', 'service_cycle_compat_fallback', 'service_cycle_skipped_lock')
|
|
1044
1264
|
AND created_at > ?
|
|
1045
1265
|
ORDER BY id DESC
|
|
1046
1266
|
LIMIT 5
|
|
@@ -1101,6 +1321,7 @@ def _macos_local_index_service_status() -> dict:
|
|
|
1101
1321
|
running = False
|
|
1102
1322
|
active_process = False
|
|
1103
1323
|
pid = ""
|
|
1324
|
+
launchctl_status = ""
|
|
1104
1325
|
|
|
1105
1326
|
code, stdout, _ = _command_output(["launchctl", "list"], timeout=2)
|
|
1106
1327
|
if code == 0:
|
|
@@ -1109,6 +1330,7 @@ def _macos_local_index_service_status() -> dict:
|
|
|
1109
1330
|
if len(parts) >= 3 and parts[-1] == LOCAL_INDEX_SERVICE_LABEL:
|
|
1110
1331
|
installed = True
|
|
1111
1332
|
pid = parts[0]
|
|
1333
|
+
launchctl_status = parts[1]
|
|
1112
1334
|
running = True
|
|
1113
1335
|
active_process = pid.isdigit() and int(pid) > 0
|
|
1114
1336
|
break
|
|
@@ -1128,6 +1350,7 @@ def _macos_local_index_service_status() -> dict:
|
|
|
1128
1350
|
"manager": "launchagent",
|
|
1129
1351
|
"label": LOCAL_INDEX_SERVICE_LABEL,
|
|
1130
1352
|
"pid": pid,
|
|
1353
|
+
"last_exit_code": launchctl_status,
|
|
1131
1354
|
"config_path": str(plist_path),
|
|
1132
1355
|
}
|
|
1133
1356
|
|
|
@@ -1135,11 +1358,22 @@ def _macos_local_index_service_status() -> dict:
|
|
|
1135
1358
|
def _windows_local_index_service_status() -> dict:
|
|
1136
1359
|
command = (
|
|
1137
1360
|
"$task = Get-ScheduledTask -TaskName 'NEXO Local Memory' -ErrorAction SilentlyContinue; "
|
|
1138
|
-
"if ($task) {
|
|
1361
|
+
"$info = if ($task) { Get-ScheduledTaskInfo -TaskName 'NEXO Local Memory' -ErrorAction SilentlyContinue }; "
|
|
1362
|
+
"if ($task) { "
|
|
1363
|
+
"$lastRun = if ($info -and $info.LastRunTime) { $info.LastRunTime.ToString('o') } else { '' }; "
|
|
1364
|
+
"$nextRun = if ($info -and $info.NextRunTime) { $info.NextRunTime.ToString('o') } else { '' }; "
|
|
1365
|
+
"$lastResult = if ($info) { [string]$info.LastTaskResult } else { '' }; "
|
|
1366
|
+
"Write-Output ($task.State.ToString() + '|' + $lastResult + '|' + $lastRun + '|' + $nextRun) "
|
|
1367
|
+
"}"
|
|
1139
1368
|
)
|
|
1140
1369
|
code, stdout, _ = _command_output(["powershell", "-NoProfile", "-Command", command], timeout=4)
|
|
1141
|
-
|
|
1370
|
+
raw = stdout.strip()
|
|
1371
|
+
parts = raw.split("|") if "|" in raw else [raw]
|
|
1372
|
+
task_state = parts[0].strip() if parts else ""
|
|
1142
1373
|
task_state_key = task_state.lower()
|
|
1374
|
+
last_task_result = parts[1].strip() if len(parts) > 1 else ""
|
|
1375
|
+
last_run_time = parts[2].strip() if len(parts) > 2 else ""
|
|
1376
|
+
next_run_time = parts[3].strip() if len(parts) > 3 else ""
|
|
1143
1377
|
installed = code == 0 and bool(task_state)
|
|
1144
1378
|
active_process = task_state_key == "running"
|
|
1145
1379
|
if not active_process:
|
|
@@ -1152,6 +1386,9 @@ def _windows_local_index_service_status() -> dict:
|
|
|
1152
1386
|
"manager": "scheduled_task",
|
|
1153
1387
|
"task_name": LOCAL_INDEX_WINDOWS_TASK,
|
|
1154
1388
|
"task_state": task_state,
|
|
1389
|
+
"last_task_result": last_task_result,
|
|
1390
|
+
"last_run_time": last_run_time,
|
|
1391
|
+
"next_run_time": next_run_time,
|
|
1155
1392
|
}
|
|
1156
1393
|
|
|
1157
1394
|
|
|
@@ -1194,41 +1431,171 @@ def _local_index_service_status() -> dict:
|
|
|
1194
1431
|
return service
|
|
1195
1432
|
|
|
1196
1433
|
|
|
1434
|
+
def _service_cycle_observation(conn) -> dict:
|
|
1435
|
+
last_success = conn.execute(
|
|
1436
|
+
"SELECT MAX(created_at) AS created_at FROM local_index_logs WHERE event='service_cycle_finished'"
|
|
1437
|
+
).fetchone()["created_at"] or 0
|
|
1438
|
+
latest = conn.execute(
|
|
1439
|
+
"""
|
|
1440
|
+
SELECT created_at, event, level, message, metadata_json
|
|
1441
|
+
FROM local_index_logs
|
|
1442
|
+
WHERE event IN ('service_cycle_finished', 'service_cycle_failed', 'service_cycle_compat_fallback', 'service_cycle_skipped_lock')
|
|
1443
|
+
ORDER BY id DESC
|
|
1444
|
+
LIMIT 1
|
|
1445
|
+
"""
|
|
1446
|
+
).fetchone()
|
|
1447
|
+
latest_error = conn.execute(
|
|
1448
|
+
"""
|
|
1449
|
+
SELECT created_at, event, level, message, metadata_json
|
|
1450
|
+
FROM local_index_logs
|
|
1451
|
+
WHERE event IN ('service_cycle_failed', 'service_cycle_compat_fallback', 'service_cycle_skipped_lock')
|
|
1452
|
+
AND created_at > ?
|
|
1453
|
+
ORDER BY id DESC
|
|
1454
|
+
LIMIT 1
|
|
1455
|
+
""",
|
|
1456
|
+
(last_success,),
|
|
1457
|
+
).fetchone()
|
|
1458
|
+
observation = {
|
|
1459
|
+
"last_success_at": float(last_success or 0),
|
|
1460
|
+
"last_error_at": 0,
|
|
1461
|
+
"last_error_code": "",
|
|
1462
|
+
"last_error_detail": "",
|
|
1463
|
+
"healthy": latest_error is None,
|
|
1464
|
+
}
|
|
1465
|
+
if latest:
|
|
1466
|
+
observation["last_heartbeat_at"] = float(latest["created_at"] or 0)
|
|
1467
|
+
if latest_error:
|
|
1468
|
+
observation["last_error_at"] = float(latest_error["created_at"] or 0)
|
|
1469
|
+
observation["last_error_code"] = latest_error["event"]
|
|
1470
|
+
observation["last_error_detail"] = f"{latest_error['message']} {latest_error['metadata_json']}"
|
|
1471
|
+
return observation
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def _service_scheduler_has_error(service: dict) -> bool:
|
|
1475
|
+
if service.get("manager") == "launchagent":
|
|
1476
|
+
code = str(service.get("last_exit_code") or "").strip()
|
|
1477
|
+
return bool(code and code not in {"0", "-"})
|
|
1478
|
+
if service.get("manager") == "scheduled_task":
|
|
1479
|
+
code = str(service.get("last_task_result") or "").strip()
|
|
1480
|
+
return bool(code and code not in {"0"})
|
|
1481
|
+
return False
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def _service_problem(service: dict) -> dict | None:
|
|
1485
|
+
if not service.get("installed"):
|
|
1486
|
+
return {
|
|
1487
|
+
"support_code": "local_index_service_not_installed",
|
|
1488
|
+
"user_message": "La memoria local aun no tiene activo el servicio en segundo plano",
|
|
1489
|
+
"recommended_action": "Reabre NEXO Desktop o actualiza a la ultima version para instalarlo automaticamente.",
|
|
1490
|
+
"technical_detail": f"manager={service.get('manager')} platform={service.get('platform')}",
|
|
1491
|
+
}
|
|
1492
|
+
if not service.get("running"):
|
|
1493
|
+
return {
|
|
1494
|
+
"support_code": "local_index_service_not_running",
|
|
1495
|
+
"user_message": "La memoria local no se esta actualizando en segundo plano",
|
|
1496
|
+
"recommended_action": "NEXO intentara recuperarlo automaticamente. Si se repite, abre soporte y diagnostico.",
|
|
1497
|
+
"technical_detail": f"manager={service.get('manager')} platform={service.get('platform')}",
|
|
1498
|
+
}
|
|
1499
|
+
if _service_scheduler_has_error(service):
|
|
1500
|
+
code = service.get("last_exit_code") or service.get("last_task_result") or ""
|
|
1501
|
+
return {
|
|
1502
|
+
"support_code": "local_index_service_last_run_failed",
|
|
1503
|
+
"user_message": "La ultima comprobacion de memoria local no termino correctamente",
|
|
1504
|
+
"recommended_action": "NEXO lo volvera a intentar automaticamente.",
|
|
1505
|
+
"technical_detail": f"last_result={code}",
|
|
1506
|
+
}
|
|
1507
|
+
if not service.get("healthy", True):
|
|
1508
|
+
return {
|
|
1509
|
+
"support_code": service.get("last_error_code") or "local_index_service_failed",
|
|
1510
|
+
"user_message": "La memoria local tuvo un problema temporal y NEXO la reintentara automaticamente",
|
|
1511
|
+
"recommended_action": "No tienes que hacer nada. Si se repite, abre soporte y diagnostico para ver el detalle.",
|
|
1512
|
+
"technical_detail": service.get("last_error_detail") or "",
|
|
1513
|
+
}
|
|
1514
|
+
return None
|
|
1515
|
+
|
|
1516
|
+
|
|
1197
1517
|
def status() -> dict:
|
|
1198
1518
|
conn = _conn()
|
|
1199
1519
|
paused = _is_paused()
|
|
1200
1520
|
assets = conn.execute(
|
|
1201
|
-
"
|
|
1521
|
+
"""
|
|
1522
|
+
SELECT COUNT(*) AS total, SUM(CASE WHEN a.status='active' THEN 1 ELSE 0 END) AS active
|
|
1523
|
+
FROM local_assets a
|
|
1524
|
+
LEFT JOIN local_index_roots r ON r.id=a.root_id
|
|
1525
|
+
WHERE COALESCE(r.status, 'active') != 'removed'
|
|
1526
|
+
"""
|
|
1202
1527
|
).fetchone()
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1528
|
+
job_rows = conn.execute(
|
|
1529
|
+
"""
|
|
1530
|
+
SELECT j.status, COUNT(*) AS total
|
|
1531
|
+
FROM local_index_jobs j
|
|
1532
|
+
JOIN local_assets a ON a.asset_id=j.asset_id
|
|
1533
|
+
LEFT JOIN local_index_roots r ON r.id=a.root_id
|
|
1534
|
+
WHERE a.status='active'
|
|
1535
|
+
AND COALESCE(r.status, 'active') != 'removed'
|
|
1536
|
+
GROUP BY j.status
|
|
1537
|
+
"""
|
|
1538
|
+
).fetchall()
|
|
1539
|
+
job_counts = {row["status"]: int(row["total"] or 0) for row in job_rows}
|
|
1540
|
+
pending = int(job_counts.get("pending", 0) or 0)
|
|
1541
|
+
running_jobs = int(job_counts.get("running", 0) or 0)
|
|
1542
|
+
failed_jobs = int(job_counts.get("failed", 0) or 0)
|
|
1543
|
+
done = int(job_counts.get("done", 0) or 0)
|
|
1544
|
+
active_jobs = pending + running_jobs + failed_jobs
|
|
1545
|
+
total_jobs = active_jobs + done
|
|
1206
1546
|
percent = 100 if total_jobs == 0 else int((done / max(total_jobs, 1)) * 100)
|
|
1207
1547
|
roots = list_roots()
|
|
1208
1548
|
volumes = []
|
|
1209
1549
|
by_volume = conn.execute(
|
|
1210
|
-
"
|
|
1550
|
+
"""
|
|
1551
|
+
SELECT a.volume_id, COUNT(*) AS files
|
|
1552
|
+
FROM local_assets a
|
|
1553
|
+
LEFT JOIN local_index_roots r ON r.id=a.root_id
|
|
1554
|
+
WHERE a.status='active'
|
|
1555
|
+
AND COALESCE(r.status, 'active') != 'removed'
|
|
1556
|
+
GROUP BY a.volume_id
|
|
1557
|
+
ORDER BY a.volume_id
|
|
1558
|
+
"""
|
|
1211
1559
|
).fetchall()
|
|
1212
1560
|
for row in by_volume:
|
|
1213
1561
|
volumes.append({"id": row["volume_id"], "label": row["volume_id"] or "Disk", "files": row["files"], "status": "active"})
|
|
1214
1562
|
service = _local_index_service_status()
|
|
1215
|
-
service
|
|
1563
|
+
service.update(_service_cycle_observation(conn))
|
|
1564
|
+
problem = _service_problem(service)
|
|
1565
|
+
service["healthy"] = problem is None
|
|
1566
|
+
service["state"] = "paused" if paused else ("attention" if problem else ("idle" if active_jobs == 0 else "indexing"))
|
|
1567
|
+
problems = _problem_rows(conn)
|
|
1568
|
+
if problem:
|
|
1569
|
+
problems.insert(0, {
|
|
1570
|
+
"user_message": problem["user_message"],
|
|
1571
|
+
"recommended_action": problem["recommended_action"],
|
|
1572
|
+
"technical_detail": problem["technical_detail"],
|
|
1573
|
+
"support_code": problem["support_code"],
|
|
1574
|
+
"severity": "warning",
|
|
1575
|
+
"retryable": True,
|
|
1576
|
+
"path": "",
|
|
1577
|
+
"phase": "service",
|
|
1578
|
+
"created_at": now(),
|
|
1579
|
+
})
|
|
1216
1580
|
return {
|
|
1217
1581
|
"ok": True,
|
|
1218
1582
|
"service": service,
|
|
1219
1583
|
"global": {
|
|
1220
|
-
"phase": "paused" if paused else ("idle" if
|
|
1584
|
+
"phase": "paused" if paused else ("service_attention" if problem else ("idle" if active_jobs == 0 else "light_extraction")),
|
|
1221
1585
|
"percent": percent,
|
|
1222
1586
|
"files_found": int(assets["total"] or 0),
|
|
1223
1587
|
"files_processed": int(done or 0),
|
|
1224
|
-
"changes_pending": int(
|
|
1588
|
+
"changes_pending": int(active_jobs or 0),
|
|
1589
|
+
"jobs_pending": pending,
|
|
1590
|
+
"jobs_running": running_jobs,
|
|
1591
|
+
"jobs_failed": failed_jobs,
|
|
1225
1592
|
"elapsed_seconds": 0,
|
|
1226
1593
|
"eta_seconds": None,
|
|
1227
1594
|
},
|
|
1228
1595
|
"volumes": volumes,
|
|
1229
1596
|
"roots": roots,
|
|
1230
1597
|
"exclusions": list_exclusions(),
|
|
1231
|
-
"problems":
|
|
1598
|
+
"problems": problems,
|
|
1232
1599
|
"permissions": [],
|
|
1233
1600
|
"models": model_status()["models"],
|
|
1234
1601
|
"support_log_available": True,
|
|
@@ -15,7 +15,15 @@ def now() -> float:
|
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def norm_path(path: str | os.PathLike[str]) -> str:
|
|
18
|
-
|
|
18
|
+
text = str(Path(path).expanduser())
|
|
19
|
+
if re.match(r"^[A-Za-z]:[\\/]*$", text):
|
|
20
|
+
return f"{text[0].upper()}:\\"
|
|
21
|
+
if text in {"/", "\\"}:
|
|
22
|
+
return text
|
|
23
|
+
stripped = text.rstrip("/\\")
|
|
24
|
+
if re.match(r"^[A-Za-z]:$", stripped):
|
|
25
|
+
return f"{stripped[0].upper()}:\\"
|
|
26
|
+
return stripped or text
|
|
19
27
|
|
|
20
28
|
|
|
21
29
|
def stable_id(prefix: str, value: str) -> str:
|
|
@@ -51,6 +51,13 @@ def log(message: str) -> None:
|
|
|
51
51
|
handle.write(line + "\n")
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def _log_event_best_effort(level: str, event: str, message: str, **metadata) -> None:
|
|
55
|
+
try:
|
|
56
|
+
log_event(level, event, message, **metadata)
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
log(f"ERROR: failed to record local-index event {event}: {type(exc).__name__}: {exc}")
|
|
59
|
+
|
|
60
|
+
|
|
54
61
|
def _read_lock() -> dict:
|
|
55
62
|
try:
|
|
56
63
|
return json.loads(LOCK_FILE.read_text(encoding="utf-8"))
|
|
@@ -121,7 +128,7 @@ def _run_index_cycle() -> dict:
|
|
|
121
128
|
live_kwargs = ("live_asset_limit", "live_dir_limit", "live_file_limit")
|
|
122
129
|
if not any(name in message for name in live_kwargs):
|
|
123
130
|
raise
|
|
124
|
-
|
|
131
|
+
_log_event_best_effort(
|
|
125
132
|
"warn",
|
|
126
133
|
"service_cycle_compat_fallback",
|
|
127
134
|
"Local memory service used compatibility fallback",
|
|
@@ -134,17 +141,18 @@ def _run_index_cycle() -> dict:
|
|
|
134
141
|
def main() -> int:
|
|
135
142
|
if not acquire_lock():
|
|
136
143
|
log("Skipped: previous local-index cycle is still running.")
|
|
144
|
+
_log_event_best_effort("warn", "service_cycle_skipped_lock", "Local memory service skipped because a previous cycle is still running")
|
|
137
145
|
return 0
|
|
138
146
|
try:
|
|
139
147
|
if os.environ.get("NEXO_LOCAL_INDEX_DISABLE_DEFAULT_ROOTS", "").strip() != "1":
|
|
140
148
|
api.ensure_default_roots()
|
|
141
149
|
result = _run_index_cycle()
|
|
142
|
-
|
|
150
|
+
_log_event_best_effort("info", "service_cycle_finished", "Local memory service cycle finished", result=result)
|
|
143
151
|
log(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
|
144
152
|
return 0 if result.get("ok") else 2
|
|
145
153
|
except Exception as exc:
|
|
146
|
-
log_event("error", "service_cycle_failed", "Local memory service cycle failed", error=type(exc).__name__)
|
|
147
154
|
log(f"ERROR: {type(exc).__name__}: {exc}")
|
|
155
|
+
_log_event_best_effort("error", "service_cycle_failed", "Local memory service cycle failed", error=type(exc).__name__)
|
|
148
156
|
return 2
|
|
149
157
|
finally:
|
|
150
158
|
release_lock()
|