nexo-brain 7.20.1 → 7.20.2
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/local_context/api.py +246 -33
- 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.2",
|
|
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.2",
|
|
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",
|
package/src/local_context/api.py
CHANGED
|
@@ -425,6 +425,48 @@ def _mark_dir_subtree_deleted(conn, dir_path: str, deleted_at: float | None = No
|
|
|
425
425
|
return len(rows)
|
|
426
426
|
|
|
427
427
|
|
|
428
|
+
def _record_index_error(
|
|
429
|
+
conn,
|
|
430
|
+
*,
|
|
431
|
+
asset_id: str = "",
|
|
432
|
+
path: str = "",
|
|
433
|
+
phase: str,
|
|
434
|
+
error_code: str,
|
|
435
|
+
user_message: str,
|
|
436
|
+
technical_detail: str,
|
|
437
|
+
retryable: bool = True,
|
|
438
|
+
) -> None:
|
|
439
|
+
conn.execute(
|
|
440
|
+
"""
|
|
441
|
+
INSERT INTO local_index_errors(asset_id, path, phase, error_code, user_message, technical_detail, retryable, created_at)
|
|
442
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
443
|
+
""",
|
|
444
|
+
(asset_id, path, phase, error_code, user_message, technical_detail, 1 if retryable else 0, now()),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _record_scan_error(conn, stats: dict | None, path: str, phase: str, exc: Exception) -> None:
|
|
449
|
+
if stats is not None:
|
|
450
|
+
stats["errors"] = int(stats.get("errors", 0) or 0) + 1
|
|
451
|
+
logged = int(stats.get("_errors_logged", 0) or 0)
|
|
452
|
+
if logged >= 20:
|
|
453
|
+
return
|
|
454
|
+
stats["_errors_logged"] = logged + 1
|
|
455
|
+
_record_index_error(
|
|
456
|
+
conn,
|
|
457
|
+
path=path,
|
|
458
|
+
phase=phase,
|
|
459
|
+
error_code=type(exc).__name__,
|
|
460
|
+
user_message="Algunas carpetas o archivos no se pudieron leer",
|
|
461
|
+
technical_detail=str(exc),
|
|
462
|
+
retryable=True,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _public_stats(stats: dict) -> dict:
|
|
467
|
+
return {key: value for key, value in stats.items() if not str(key).startswith("_")}
|
|
468
|
+
|
|
469
|
+
|
|
428
470
|
def enqueue_job(conn, asset_id: str, job_type: str, *, priority: int = 50) -> str:
|
|
429
471
|
job_id = stable_id("job", f"{asset_id}:{job_type}")
|
|
430
472
|
conn.execute(
|
|
@@ -447,6 +489,7 @@ def _iter_files(
|
|
|
447
489
|
limit: int | None = None,
|
|
448
490
|
start_after: str = "",
|
|
449
491
|
seen_at: float | None = None,
|
|
492
|
+
stats: dict | None = None,
|
|
450
493
|
):
|
|
451
494
|
seen_at = seen_at or now()
|
|
452
495
|
seen_dirs: set[tuple[int, int]] = set()
|
|
@@ -461,7 +504,8 @@ def _iter_files(
|
|
|
461
504
|
continue
|
|
462
505
|
try:
|
|
463
506
|
st = current.stat()
|
|
464
|
-
except Exception:
|
|
507
|
+
except Exception as exc:
|
|
508
|
+
_record_scan_error(conn, stats, str(current), "quick_index", exc)
|
|
465
509
|
continue
|
|
466
510
|
key = (getattr(st, "st_dev", 0), getattr(st, "st_ino", 0))
|
|
467
511
|
if key in seen_dirs:
|
|
@@ -470,7 +514,8 @@ def _iter_files(
|
|
|
470
514
|
_upsert_dir(conn, root_id, current, seen_at, st)
|
|
471
515
|
try:
|
|
472
516
|
entries = sorted(current.iterdir(), key=lambda item: str(item).lower())
|
|
473
|
-
except Exception:
|
|
517
|
+
except Exception as exc:
|
|
518
|
+
_record_scan_error(conn, stats, str(current), "quick_index", exc)
|
|
474
519
|
continue
|
|
475
520
|
dirs: list[Path] = []
|
|
476
521
|
for entry in entries:
|
|
@@ -577,8 +622,8 @@ def _reconcile_known_assets(conn, exclusions: list[str], *, limit: int) -> dict:
|
|
|
577
622
|
continue
|
|
578
623
|
st = file_path.stat()
|
|
579
624
|
fingerprint = quick_fingerprint(file_path, st)
|
|
580
|
-
except Exception:
|
|
581
|
-
stats
|
|
625
|
+
except Exception as exc:
|
|
626
|
+
_record_scan_error(conn, stats, path, "live_reconcile", exc)
|
|
582
627
|
continue
|
|
583
628
|
if fingerprint != row["quick_fingerprint"]:
|
|
584
629
|
_, changed, state = _upsert_asset(conn, int(row["root_id"] or 0), file_path, seen_at, int(row["depth"] or 2))
|
|
@@ -647,8 +692,8 @@ def _scan_known_directory(
|
|
|
647
692
|
if not current.is_dir():
|
|
648
693
|
continue
|
|
649
694
|
entries = sorted(current.iterdir(), key=lambda item: str(item).lower())
|
|
650
|
-
except Exception:
|
|
651
|
-
stats
|
|
695
|
+
except Exception as exc:
|
|
696
|
+
_record_scan_error(conn, stats, str(current), "live_reconcile", exc)
|
|
652
697
|
continue
|
|
653
698
|
scanned_dirs += 1
|
|
654
699
|
stats["dirs_scanned"] += 1
|
|
@@ -679,8 +724,8 @@ def _scan_known_directory(
|
|
|
679
724
|
stats["files_changed"] += 1
|
|
680
725
|
if state != "ok":
|
|
681
726
|
stats["errors"] += 1
|
|
682
|
-
except Exception:
|
|
683
|
-
stats
|
|
727
|
+
except Exception as exc:
|
|
728
|
+
_record_scan_error(conn, stats, str(entry), "live_reconcile", exc)
|
|
684
729
|
deleted_files, deleted_dirs = _prune_missing_children(conn, current, seen_files, seen_dirs, seen_at)
|
|
685
730
|
stats["files_deleted"] += deleted_files
|
|
686
731
|
stats["dirs_deleted"] += deleted_dirs
|
|
@@ -731,8 +776,8 @@ def _reconcile_known_dirs(conn, exclusions: list[str], *, dir_limit: int, file_l
|
|
|
731
776
|
continue
|
|
732
777
|
st = dir_path.stat()
|
|
733
778
|
fingerprint = _dir_fingerprint(dir_path, st)
|
|
734
|
-
except Exception:
|
|
735
|
-
stats
|
|
779
|
+
except Exception as exc:
|
|
780
|
+
_record_scan_error(conn, stats, str(dir_path), "live_reconcile", exc)
|
|
736
781
|
continue
|
|
737
782
|
if fingerprint != row["quick_fingerprint"]:
|
|
738
783
|
stats["changed"] += 1
|
|
@@ -773,9 +818,18 @@ def reconcile_live_changes(
|
|
|
773
818
|
+ int(dir_stats.get("dirs_deleted", 0))
|
|
774
819
|
+ int(dir_stats.get("excluded_dirs", 0))
|
|
775
820
|
)
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
821
|
+
error_total = int(asset_stats.get("errors", 0) or 0) + int(dir_stats.get("errors", 0) or 0)
|
|
822
|
+
public_asset_stats = _public_stats(asset_stats)
|
|
823
|
+
public_dir_stats = _public_stats(dir_stats)
|
|
824
|
+
if changed_total or error_total:
|
|
825
|
+
log_event(
|
|
826
|
+
"warn" if error_total else "info",
|
|
827
|
+
"live_reconcile_finished",
|
|
828
|
+
"Local memory live changes reconciled",
|
|
829
|
+
assets=public_asset_stats,
|
|
830
|
+
dirs=public_dir_stats,
|
|
831
|
+
)
|
|
832
|
+
return {"ok": True, "assets": public_asset_stats, "dirs": public_dir_stats}
|
|
779
833
|
|
|
780
834
|
|
|
781
835
|
def scan_once(*, limit: int | None = None) -> dict:
|
|
@@ -814,6 +868,7 @@ def scan_once(*, limit: int | None = None) -> dict:
|
|
|
814
868
|
limit=limit,
|
|
815
869
|
start_after=str(checkpoint["current_path"] or ""),
|
|
816
870
|
seen_at=cycle_started_at,
|
|
871
|
+
stats=totals,
|
|
817
872
|
):
|
|
818
873
|
asset_id, changed, state = _upsert_asset(conn, root_id, file_path, cycle_started_at, int(root["depth"] or 2))
|
|
819
874
|
last_seen_path = norm_path(file_path)
|
|
@@ -833,7 +888,7 @@ def scan_once(*, limit: int | None = None) -> dict:
|
|
|
833
888
|
path=redact_path(str(root_path)),
|
|
834
889
|
)
|
|
835
890
|
if last_seen_path:
|
|
836
|
-
_save_checkpoint(conn, root_id, last_seen_path, cycle_started_at=cycle_started_at, totals=totals)
|
|
891
|
+
_save_checkpoint(conn, root_id, last_seen_path, cycle_started_at=cycle_started_at, totals=_public_stats(totals))
|
|
837
892
|
else:
|
|
838
893
|
rows = conn.execute(
|
|
839
894
|
"SELECT asset_id FROM local_assets WHERE root_id=? AND status='active' AND last_seen_at < ?",
|
|
@@ -850,8 +905,9 @@ def scan_once(*, limit: int | None = None) -> dict:
|
|
|
850
905
|
(now(), now(), root_id),
|
|
851
906
|
)
|
|
852
907
|
conn.commit()
|
|
853
|
-
|
|
854
|
-
|
|
908
|
+
public_totals = _public_stats(totals)
|
|
909
|
+
log_event("warn" if public_totals.get("errors") else "info", "scan_finished", "Local memory scan finished", **public_totals)
|
|
910
|
+
return {"ok": True, **public_totals}
|
|
855
911
|
|
|
856
912
|
|
|
857
913
|
def _latest_version_id(conn, asset_id: str) -> str:
|
|
@@ -913,11 +969,35 @@ def _replace_entities(conn, asset_id: str, version_id: str, values: list[str]) -
|
|
|
913
969
|
)
|
|
914
970
|
|
|
915
971
|
|
|
972
|
+
def _requeue_due_jobs(conn) -> dict:
|
|
973
|
+
current = now()
|
|
974
|
+
failed = conn.execute(
|
|
975
|
+
"""
|
|
976
|
+
UPDATE local_index_jobs
|
|
977
|
+
SET status='pending', claimed_by='', lease_expires_at=NULL, updated_at=?
|
|
978
|
+
WHERE status='failed' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
|
979
|
+
""",
|
|
980
|
+
(current, current),
|
|
981
|
+
).rowcount
|
|
982
|
+
expired = conn.execute(
|
|
983
|
+
"""
|
|
984
|
+
UPDATE local_index_jobs
|
|
985
|
+
SET status='pending', claimed_by='', lease_expires_at=NULL, updated_at=?
|
|
986
|
+
WHERE status='running' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?
|
|
987
|
+
""",
|
|
988
|
+
(current, current),
|
|
989
|
+
).rowcount
|
|
990
|
+
if failed or expired:
|
|
991
|
+
log_event("warn", "jobs_requeued", "Local memory recovered stalled jobs", failed=failed, expired=expired)
|
|
992
|
+
return {"failed": int(failed or 0), "expired": int(expired or 0)}
|
|
993
|
+
|
|
994
|
+
|
|
916
995
|
def process_jobs(*, limit: int = 100) -> dict:
|
|
917
996
|
conn = _conn()
|
|
918
997
|
if _is_paused():
|
|
919
998
|
log_event("info", "jobs_skipped_paused", "Local memory jobs skipped because indexing is paused")
|
|
920
999
|
return {"ok": True, "paused": True, "processed": 0, "failed": 0}
|
|
1000
|
+
recovered = _requeue_due_jobs(conn)
|
|
921
1001
|
rows = conn.execute(
|
|
922
1002
|
"""
|
|
923
1003
|
SELECT j.*, a.path, a.depth, a.status AS asset_status
|
|
@@ -976,17 +1056,20 @@ def process_jobs(*, limit: int = 100) -> dict:
|
|
|
976
1056
|
""",
|
|
977
1057
|
(now() + 3600, type(exc).__name__, now(), job_id),
|
|
978
1058
|
)
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1059
|
+
_record_index_error(
|
|
1060
|
+
conn,
|
|
1061
|
+
asset_id=asset_id,
|
|
1062
|
+
path=row["path"],
|
|
1063
|
+
phase=job_type,
|
|
1064
|
+
error_code=type(exc).__name__,
|
|
1065
|
+
user_message="Algunos archivos no se pudieron leer",
|
|
1066
|
+
technical_detail=str(exc),
|
|
1067
|
+
retryable=True,
|
|
985
1068
|
)
|
|
986
1069
|
conn.commit()
|
|
987
1070
|
if processed or failed:
|
|
988
1071
|
log_event("info", "jobs_processed", "Local memory jobs processed", processed=processed, failed=failed)
|
|
989
|
-
return {"ok": True, "processed": processed, "failed": failed}
|
|
1072
|
+
return {"ok": True, "processed": processed, "failed": failed, "recovered": recovered}
|
|
990
1073
|
|
|
991
1074
|
|
|
992
1075
|
def run_once(
|
|
@@ -1000,6 +1083,12 @@ def run_once(
|
|
|
1000
1083
|
) -> dict:
|
|
1001
1084
|
if root:
|
|
1002
1085
|
add_root(root)
|
|
1086
|
+
elif (
|
|
1087
|
+
os.environ.get("NEXO_LOCAL_INDEX_DISABLE_DEFAULT_ROOTS", "").strip() != "1"
|
|
1088
|
+
and os.environ.get("NEXO_SKIP_FS_INDEX", "").strip() != "1"
|
|
1089
|
+
and not list_roots()
|
|
1090
|
+
):
|
|
1091
|
+
ensure_default_roots()
|
|
1003
1092
|
live_result = reconcile_live_changes(
|
|
1004
1093
|
asset_limit=live_asset_limit,
|
|
1005
1094
|
dir_limit=live_dir_limit,
|
|
@@ -1040,7 +1129,7 @@ def _problem_rows(conn) -> list[dict]:
|
|
|
1040
1129
|
"""
|
|
1041
1130
|
SELECT created_at, level, event, message, metadata_json
|
|
1042
1131
|
FROM local_index_logs
|
|
1043
|
-
WHERE event IN ('service_cycle_failed', 'service_cycle_compat_fallback')
|
|
1132
|
+
WHERE event IN ('service_cycle_failed', 'service_cycle_compat_fallback', 'service_cycle_skipped_lock')
|
|
1044
1133
|
AND created_at > ?
|
|
1045
1134
|
ORDER BY id DESC
|
|
1046
1135
|
LIMIT 5
|
|
@@ -1101,6 +1190,7 @@ def _macos_local_index_service_status() -> dict:
|
|
|
1101
1190
|
running = False
|
|
1102
1191
|
active_process = False
|
|
1103
1192
|
pid = ""
|
|
1193
|
+
launchctl_status = ""
|
|
1104
1194
|
|
|
1105
1195
|
code, stdout, _ = _command_output(["launchctl", "list"], timeout=2)
|
|
1106
1196
|
if code == 0:
|
|
@@ -1109,6 +1199,7 @@ def _macos_local_index_service_status() -> dict:
|
|
|
1109
1199
|
if len(parts) >= 3 and parts[-1] == LOCAL_INDEX_SERVICE_LABEL:
|
|
1110
1200
|
installed = True
|
|
1111
1201
|
pid = parts[0]
|
|
1202
|
+
launchctl_status = parts[1]
|
|
1112
1203
|
running = True
|
|
1113
1204
|
active_process = pid.isdigit() and int(pid) > 0
|
|
1114
1205
|
break
|
|
@@ -1128,6 +1219,7 @@ def _macos_local_index_service_status() -> dict:
|
|
|
1128
1219
|
"manager": "launchagent",
|
|
1129
1220
|
"label": LOCAL_INDEX_SERVICE_LABEL,
|
|
1130
1221
|
"pid": pid,
|
|
1222
|
+
"last_exit_code": launchctl_status,
|
|
1131
1223
|
"config_path": str(plist_path),
|
|
1132
1224
|
}
|
|
1133
1225
|
|
|
@@ -1135,11 +1227,22 @@ def _macos_local_index_service_status() -> dict:
|
|
|
1135
1227
|
def _windows_local_index_service_status() -> dict:
|
|
1136
1228
|
command = (
|
|
1137
1229
|
"$task = Get-ScheduledTask -TaskName 'NEXO Local Memory' -ErrorAction SilentlyContinue; "
|
|
1138
|
-
"if ($task) {
|
|
1230
|
+
"$info = if ($task) { Get-ScheduledTaskInfo -TaskName 'NEXO Local Memory' -ErrorAction SilentlyContinue }; "
|
|
1231
|
+
"if ($task) { "
|
|
1232
|
+
"$lastRun = if ($info -and $info.LastRunTime) { $info.LastRunTime.ToString('o') } else { '' }; "
|
|
1233
|
+
"$nextRun = if ($info -and $info.NextRunTime) { $info.NextRunTime.ToString('o') } else { '' }; "
|
|
1234
|
+
"$lastResult = if ($info) { [string]$info.LastTaskResult } else { '' }; "
|
|
1235
|
+
"Write-Output ($task.State.ToString() + '|' + $lastResult + '|' + $lastRun + '|' + $nextRun) "
|
|
1236
|
+
"}"
|
|
1139
1237
|
)
|
|
1140
1238
|
code, stdout, _ = _command_output(["powershell", "-NoProfile", "-Command", command], timeout=4)
|
|
1141
|
-
|
|
1239
|
+
raw = stdout.strip()
|
|
1240
|
+
parts = raw.split("|") if "|" in raw else [raw]
|
|
1241
|
+
task_state = parts[0].strip() if parts else ""
|
|
1142
1242
|
task_state_key = task_state.lower()
|
|
1243
|
+
last_task_result = parts[1].strip() if len(parts) > 1 else ""
|
|
1244
|
+
last_run_time = parts[2].strip() if len(parts) > 2 else ""
|
|
1245
|
+
next_run_time = parts[3].strip() if len(parts) > 3 else ""
|
|
1143
1246
|
installed = code == 0 and bool(task_state)
|
|
1144
1247
|
active_process = task_state_key == "running"
|
|
1145
1248
|
if not active_process:
|
|
@@ -1152,6 +1255,9 @@ def _windows_local_index_service_status() -> dict:
|
|
|
1152
1255
|
"manager": "scheduled_task",
|
|
1153
1256
|
"task_name": LOCAL_INDEX_WINDOWS_TASK,
|
|
1154
1257
|
"task_state": task_state,
|
|
1258
|
+
"last_task_result": last_task_result,
|
|
1259
|
+
"last_run_time": last_run_time,
|
|
1260
|
+
"next_run_time": next_run_time,
|
|
1155
1261
|
}
|
|
1156
1262
|
|
|
1157
1263
|
|
|
@@ -1194,15 +1300,103 @@ def _local_index_service_status() -> dict:
|
|
|
1194
1300
|
return service
|
|
1195
1301
|
|
|
1196
1302
|
|
|
1303
|
+
def _service_cycle_observation(conn) -> dict:
|
|
1304
|
+
last_success = conn.execute(
|
|
1305
|
+
"SELECT MAX(created_at) AS created_at FROM local_index_logs WHERE event='service_cycle_finished'"
|
|
1306
|
+
).fetchone()["created_at"] or 0
|
|
1307
|
+
latest = conn.execute(
|
|
1308
|
+
"""
|
|
1309
|
+
SELECT created_at, event, level, message, metadata_json
|
|
1310
|
+
FROM local_index_logs
|
|
1311
|
+
WHERE event IN ('service_cycle_finished', 'service_cycle_failed', 'service_cycle_compat_fallback', 'service_cycle_skipped_lock')
|
|
1312
|
+
ORDER BY id DESC
|
|
1313
|
+
LIMIT 1
|
|
1314
|
+
"""
|
|
1315
|
+
).fetchone()
|
|
1316
|
+
latest_error = conn.execute(
|
|
1317
|
+
"""
|
|
1318
|
+
SELECT created_at, event, level, message, metadata_json
|
|
1319
|
+
FROM local_index_logs
|
|
1320
|
+
WHERE event IN ('service_cycle_failed', 'service_cycle_compat_fallback', 'service_cycle_skipped_lock')
|
|
1321
|
+
AND created_at > ?
|
|
1322
|
+
ORDER BY id DESC
|
|
1323
|
+
LIMIT 1
|
|
1324
|
+
""",
|
|
1325
|
+
(last_success,),
|
|
1326
|
+
).fetchone()
|
|
1327
|
+
observation = {
|
|
1328
|
+
"last_success_at": float(last_success or 0),
|
|
1329
|
+
"last_error_at": 0,
|
|
1330
|
+
"last_error_code": "",
|
|
1331
|
+
"last_error_detail": "",
|
|
1332
|
+
"healthy": latest_error is None,
|
|
1333
|
+
}
|
|
1334
|
+
if latest:
|
|
1335
|
+
observation["last_heartbeat_at"] = float(latest["created_at"] or 0)
|
|
1336
|
+
if latest_error:
|
|
1337
|
+
observation["last_error_at"] = float(latest_error["created_at"] or 0)
|
|
1338
|
+
observation["last_error_code"] = latest_error["event"]
|
|
1339
|
+
observation["last_error_detail"] = f"{latest_error['message']} {latest_error['metadata_json']}"
|
|
1340
|
+
return observation
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _service_scheduler_has_error(service: dict) -> bool:
|
|
1344
|
+
if service.get("manager") == "launchagent":
|
|
1345
|
+
code = str(service.get("last_exit_code") or "").strip()
|
|
1346
|
+
return bool(code and code not in {"0", "-"})
|
|
1347
|
+
if service.get("manager") == "scheduled_task":
|
|
1348
|
+
code = str(service.get("last_task_result") or "").strip()
|
|
1349
|
+
return bool(code and code not in {"0"})
|
|
1350
|
+
return False
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def _service_problem(service: dict) -> dict | None:
|
|
1354
|
+
if not service.get("installed"):
|
|
1355
|
+
return {
|
|
1356
|
+
"support_code": "local_index_service_not_installed",
|
|
1357
|
+
"user_message": "La memoria local aun no tiene activo el servicio en segundo plano",
|
|
1358
|
+
"recommended_action": "Reabre NEXO Desktop o actualiza a la ultima version para instalarlo automaticamente.",
|
|
1359
|
+
"technical_detail": f"manager={service.get('manager')} platform={service.get('platform')}",
|
|
1360
|
+
}
|
|
1361
|
+
if not service.get("running"):
|
|
1362
|
+
return {
|
|
1363
|
+
"support_code": "local_index_service_not_running",
|
|
1364
|
+
"user_message": "La memoria local no se esta actualizando en segundo plano",
|
|
1365
|
+
"recommended_action": "NEXO intentara recuperarlo automaticamente. Si se repite, abre soporte y diagnostico.",
|
|
1366
|
+
"technical_detail": f"manager={service.get('manager')} platform={service.get('platform')}",
|
|
1367
|
+
}
|
|
1368
|
+
if _service_scheduler_has_error(service):
|
|
1369
|
+
code = service.get("last_exit_code") or service.get("last_task_result") or ""
|
|
1370
|
+
return {
|
|
1371
|
+
"support_code": "local_index_service_last_run_failed",
|
|
1372
|
+
"user_message": "La ultima comprobacion de memoria local no termino correctamente",
|
|
1373
|
+
"recommended_action": "NEXO lo volvera a intentar automaticamente.",
|
|
1374
|
+
"technical_detail": f"last_result={code}",
|
|
1375
|
+
}
|
|
1376
|
+
if not service.get("healthy", True):
|
|
1377
|
+
return {
|
|
1378
|
+
"support_code": service.get("last_error_code") or "local_index_service_failed",
|
|
1379
|
+
"user_message": "La memoria local tuvo un problema temporal y NEXO la reintentara automaticamente",
|
|
1380
|
+
"recommended_action": "No tienes que hacer nada. Si se repite, abre soporte y diagnostico para ver el detalle.",
|
|
1381
|
+
"technical_detail": service.get("last_error_detail") or "",
|
|
1382
|
+
}
|
|
1383
|
+
return None
|
|
1384
|
+
|
|
1385
|
+
|
|
1197
1386
|
def status() -> dict:
|
|
1198
1387
|
conn = _conn()
|
|
1199
1388
|
paused = _is_paused()
|
|
1200
1389
|
assets = conn.execute(
|
|
1201
1390
|
"SELECT COUNT(*) AS total, SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) AS active FROM local_assets"
|
|
1202
1391
|
).fetchone()
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1392
|
+
job_rows = conn.execute("SELECT status, COUNT(*) AS total FROM local_index_jobs GROUP BY status").fetchall()
|
|
1393
|
+
job_counts = {row["status"]: int(row["total"] or 0) for row in job_rows}
|
|
1394
|
+
pending = int(job_counts.get("pending", 0) or 0)
|
|
1395
|
+
running_jobs = int(job_counts.get("running", 0) or 0)
|
|
1396
|
+
failed_jobs = int(job_counts.get("failed", 0) or 0)
|
|
1397
|
+
done = int(job_counts.get("done", 0) or 0)
|
|
1398
|
+
active_jobs = pending + running_jobs + failed_jobs
|
|
1399
|
+
total_jobs = active_jobs + done
|
|
1206
1400
|
percent = 100 if total_jobs == 0 else int((done / max(total_jobs, 1)) * 100)
|
|
1207
1401
|
roots = list_roots()
|
|
1208
1402
|
volumes = []
|
|
@@ -1212,23 +1406,42 @@ def status() -> dict:
|
|
|
1212
1406
|
for row in by_volume:
|
|
1213
1407
|
volumes.append({"id": row["volume_id"], "label": row["volume_id"] or "Disk", "files": row["files"], "status": "active"})
|
|
1214
1408
|
service = _local_index_service_status()
|
|
1215
|
-
service
|
|
1409
|
+
service.update(_service_cycle_observation(conn))
|
|
1410
|
+
problem = _service_problem(service)
|
|
1411
|
+
service["healthy"] = problem is None
|
|
1412
|
+
service["state"] = "paused" if paused else ("attention" if problem else ("idle" if active_jobs == 0 else "indexing"))
|
|
1413
|
+
problems = _problem_rows(conn)
|
|
1414
|
+
if problem:
|
|
1415
|
+
problems.insert(0, {
|
|
1416
|
+
"user_message": problem["user_message"],
|
|
1417
|
+
"recommended_action": problem["recommended_action"],
|
|
1418
|
+
"technical_detail": problem["technical_detail"],
|
|
1419
|
+
"support_code": problem["support_code"],
|
|
1420
|
+
"severity": "warning",
|
|
1421
|
+
"retryable": True,
|
|
1422
|
+
"path": "",
|
|
1423
|
+
"phase": "service",
|
|
1424
|
+
"created_at": now(),
|
|
1425
|
+
})
|
|
1216
1426
|
return {
|
|
1217
1427
|
"ok": True,
|
|
1218
1428
|
"service": service,
|
|
1219
1429
|
"global": {
|
|
1220
|
-
"phase": "paused" if paused else ("idle" if
|
|
1430
|
+
"phase": "paused" if paused else ("service_attention" if problem else ("idle" if active_jobs == 0 else "light_extraction")),
|
|
1221
1431
|
"percent": percent,
|
|
1222
1432
|
"files_found": int(assets["total"] or 0),
|
|
1223
1433
|
"files_processed": int(done or 0),
|
|
1224
|
-
"changes_pending": int(
|
|
1434
|
+
"changes_pending": int(active_jobs or 0),
|
|
1435
|
+
"jobs_pending": pending,
|
|
1436
|
+
"jobs_running": running_jobs,
|
|
1437
|
+
"jobs_failed": failed_jobs,
|
|
1225
1438
|
"elapsed_seconds": 0,
|
|
1226
1439
|
"eta_seconds": None,
|
|
1227
1440
|
},
|
|
1228
1441
|
"volumes": volumes,
|
|
1229
1442
|
"roots": roots,
|
|
1230
1443
|
"exclusions": list_exclusions(),
|
|
1231
|
-
"problems":
|
|
1444
|
+
"problems": problems,
|
|
1232
1445
|
"permissions": [],
|
|
1233
1446
|
"models": model_status()["models"],
|
|
1234
1447
|
"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()
|