nexo-brain 7.23.2 → 7.23.4
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 +7 -1
- package/package.json +1 -1
- package/scripts/sync_release_artifacts.py +28 -0
- package/src/auto_update.py +25 -47
- package/src/automation_reconciler.py +383 -0
- package/src/automation_supervisor.py +86 -9
- package/src/backup_retention.py +70 -0
- package/src/cli.py +55 -2
- package/src/cognitive/_core.py +4 -3
- package/src/cognitive_paths.py +194 -0
- package/src/dashboard/app.py +2 -1
- package/src/db/_episodic.py +85 -7
- package/src/db/_schema.py +81 -0
- package/src/db/_skills.py +3 -3
- package/src/disk_recovery/__init__.py +11 -0
- package/src/disk_recovery/handlers/__init__.py +1 -0
- package/src/disk_recovery/handlers/common.py +37 -0
- package/src/disk_recovery/handlers/macos.py +39 -0
- package/src/disk_recovery/handlers/windows.py +49 -0
- package/src/disk_recovery/registry.py +135 -0
- package/src/doctor/providers/boot.py +115 -15
- package/src/kg_populate.py +2 -5
- package/src/paths.py +321 -5
- package/src/plugins/update.py +14 -36
- package/src/pre_answer_router.py +21 -0
- package/src/runtime_service.py +30 -3
- package/src/runtime_versioning.py +272 -10
- package/src/script_registry.py +3 -2
- package/src/scripts/backfill_task_owner.py +10 -4
- package/src/scripts/deep-sleep/apply_findings.py +2 -5
- package/src/scripts/deep-sleep/collect.py +2 -5
- package/src/scripts/nexo-cognitive-decay.py +2 -1
- package/src/scripts/nexo-daily-self-audit.py +36 -10
- package/src/scripts/nexo-followup-runner.py +1 -1
- package/src/scripts/nexo-immune.py +2 -1
- package/src/scripts/nexo-migrate.py +2 -3
- package/src/scripts/post_disk_recovery_sweep.py +75 -0
- package/src/scripts/prune_runtime_backups.py +78 -11
- package/src/server.py +13 -1
- package/src/storage_router.py +2 -3
- package/src/support_snapshot.py +25 -0
- package/src/transcript_index.py +234 -0
- package/src/transcript_utils.py +31 -8
- package/src/user_data_portability.py +2 -3
- package/tool-enforcement-map.json +15 -0
package/src/db/_episodic.py
CHANGED
|
@@ -318,6 +318,61 @@ def cleanup_old_diaries(retention_days: int = 180) -> int:
|
|
|
318
318
|
return cursor.rowcount
|
|
319
319
|
|
|
320
320
|
|
|
321
|
+
def _diary_quality_tier(source: str = "", summary: str = "", mental_state: str = "") -> str:
|
|
322
|
+
normalized_source = (source or "").strip().lower()
|
|
323
|
+
blob = f"{summary or ''} {mental_state or ''}".lower()
|
|
324
|
+
if normalized_source in {"human", "operator", "manual"}:
|
|
325
|
+
return "human_authored"
|
|
326
|
+
if normalized_source == "auto-close":
|
|
327
|
+
if "0 heartbeats" in blob or "minimal diary" in blob or "[auto-" in blob:
|
|
328
|
+
return "auto_close_minimal"
|
|
329
|
+
return "agent_authored"
|
|
330
|
+
if normalized_source in {"cron", "automation"} or "[auto-" in blob:
|
|
331
|
+
return "fallback_minimal"
|
|
332
|
+
return "agent_authored"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _diary_quality_score(
|
|
336
|
+
*,
|
|
337
|
+
summary: str = "",
|
|
338
|
+
decisions: str = "",
|
|
339
|
+
pending: str = "",
|
|
340
|
+
context_next: str = "",
|
|
341
|
+
self_critique: str = "",
|
|
342
|
+
mental_state: str = "",
|
|
343
|
+
tier: str = "",
|
|
344
|
+
) -> int:
|
|
345
|
+
score = 0
|
|
346
|
+
if (summary or "").strip():
|
|
347
|
+
score += 25
|
|
348
|
+
if (decisions or "").strip():
|
|
349
|
+
score += 20
|
|
350
|
+
if (pending or "").strip():
|
|
351
|
+
score += 15
|
|
352
|
+
if (context_next or "").strip():
|
|
353
|
+
score += 20
|
|
354
|
+
if (self_critique or "").strip():
|
|
355
|
+
score += 20
|
|
356
|
+
if (mental_state or "").strip():
|
|
357
|
+
score += 5
|
|
358
|
+
if tier in {"auto_close_minimal", "fallback_minimal"}:
|
|
359
|
+
score = max(0, score - 40)
|
|
360
|
+
if tier == "human_authored":
|
|
361
|
+
score += 10
|
|
362
|
+
return min(score, 100)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _diary_order_sql() -> str:
|
|
366
|
+
return (
|
|
367
|
+
"CASE quality_tier "
|
|
368
|
+
"WHEN 'human_authored' THEN 0 "
|
|
369
|
+
"WHEN 'agent_authored' THEN 1 "
|
|
370
|
+
"WHEN 'auto_close_minimal' THEN 2 "
|
|
371
|
+
"WHEN 'fallback_minimal' THEN 3 "
|
|
372
|
+
"ELSE 4 END, quality_score DESC, created_at DESC"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
321
376
|
def write_session_diary(session_id: str, decisions: str, summary: str,
|
|
322
377
|
discarded: str = '', pending: str = '',
|
|
323
378
|
context_next: str = '', mental_state: str = '',
|
|
@@ -326,10 +381,34 @@ def write_session_diary(session_id: str, decisions: str, summary: str,
|
|
|
326
381
|
"""Write a session diary entry with mental state and self-critique for continuity."""
|
|
327
382
|
conn = get_db()
|
|
328
383
|
cleanup_old_diaries()
|
|
384
|
+
quality_tier = _diary_quality_tier(source=source, summary=summary, mental_state=mental_state)
|
|
385
|
+
quality_score = _diary_quality_score(
|
|
386
|
+
summary=summary,
|
|
387
|
+
decisions=decisions,
|
|
388
|
+
pending=pending,
|
|
389
|
+
context_next=context_next,
|
|
390
|
+
self_critique=self_critique,
|
|
391
|
+
mental_state=mental_state,
|
|
392
|
+
tier=quality_tier,
|
|
393
|
+
)
|
|
329
394
|
cursor = conn.execute(
|
|
330
|
-
"INSERT INTO session_diary (session_id, decisions, discarded, pending, context_next, mental_state, summary, domain, user_signals, self_critique, source) "
|
|
331
|
-
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
332
|
-
(
|
|
395
|
+
"INSERT INTO session_diary (session_id, decisions, discarded, pending, context_next, mental_state, summary, domain, user_signals, self_critique, source, quality_tier, quality_score) "
|
|
396
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
397
|
+
(
|
|
398
|
+
session_id,
|
|
399
|
+
decisions,
|
|
400
|
+
discarded,
|
|
401
|
+
pending,
|
|
402
|
+
context_next,
|
|
403
|
+
mental_state,
|
|
404
|
+
summary,
|
|
405
|
+
domain,
|
|
406
|
+
user_signals,
|
|
407
|
+
self_critique,
|
|
408
|
+
source,
|
|
409
|
+
quality_tier,
|
|
410
|
+
quality_score,
|
|
411
|
+
)
|
|
333
412
|
)
|
|
334
413
|
conn.commit()
|
|
335
414
|
did = cursor.lastrowid
|
|
@@ -601,19 +680,19 @@ def read_session_diary(session_id: str = '', last_n: int = 3, last_day: bool = F
|
|
|
601
680
|
|
|
602
681
|
if session_id:
|
|
603
682
|
rows = conn.execute(
|
|
604
|
-
f"SELECT * FROM session_diary WHERE session_id = ?{domain_clause} ORDER BY
|
|
683
|
+
f"SELECT * FROM session_diary WHERE session_id = ?{domain_clause} ORDER BY {_diary_order_sql()}",
|
|
605
684
|
(session_id,) + domain_params
|
|
606
685
|
).fetchall()
|
|
607
686
|
elif last_day:
|
|
608
687
|
rows = conn.execute(
|
|
609
688
|
f"SELECT * FROM session_diary "
|
|
610
689
|
f"WHERE created_at >= datetime('now', '-36 hours'){domain_clause}{source_clause} "
|
|
611
|
-
f"ORDER BY
|
|
690
|
+
f"ORDER BY {_diary_order_sql()}",
|
|
612
691
|
domain_params
|
|
613
692
|
).fetchall()
|
|
614
693
|
else:
|
|
615
694
|
rows = conn.execute(
|
|
616
|
-
f"SELECT * FROM session_diary WHERE 1=1{domain_clause}{source_clause} ORDER BY
|
|
695
|
+
f"SELECT * FROM session_diary WHERE 1=1{domain_clause}{source_clause} ORDER BY {_diary_order_sql()} LIMIT ?",
|
|
617
696
|
domain_params + (last_n,)
|
|
618
697
|
).fetchall()
|
|
619
698
|
return [dict(r) for r in rows]
|
|
@@ -759,4 +838,3 @@ def recall(query: str, days: int = 30) -> list[dict]:
|
|
|
759
838
|
|
|
760
839
|
results.sort(key=lambda r: r.get('created_at', ''), reverse=True)
|
|
761
840
|
return results[:20]
|
|
762
|
-
|
package/src/db/_schema.py
CHANGED
|
@@ -2002,6 +2002,84 @@ def _m64_local_context_live_dirs(conn):
|
|
|
2002
2002
|
)
|
|
2003
2003
|
|
|
2004
2004
|
|
|
2005
|
+
def _backfill_diary_quality(conn):
|
|
2006
|
+
for table in ("session_diary", "diary_archive"):
|
|
2007
|
+
conn.execute(f"""
|
|
2008
|
+
UPDATE {table}
|
|
2009
|
+
SET quality_tier = CASE
|
|
2010
|
+
WHEN source = 'auto-close' THEN 'auto_close_minimal'
|
|
2011
|
+
WHEN source IN ('cron', 'automation') OR COALESCE(summary, '') LIKE '[AUTO-%' THEN 'fallback_minimal'
|
|
2012
|
+
ELSE 'agent_authored'
|
|
2013
|
+
END
|
|
2014
|
+
WHERE quality_tier IS NULL
|
|
2015
|
+
OR quality_tier = ''
|
|
2016
|
+
OR quality_tier = 'agent_authored'
|
|
2017
|
+
""")
|
|
2018
|
+
conn.execute(f"""
|
|
2019
|
+
UPDATE {table}
|
|
2020
|
+
SET quality_score =
|
|
2021
|
+
CASE WHEN COALESCE(summary, '') != '' THEN 25 ELSE 0 END +
|
|
2022
|
+
CASE WHEN COALESCE(decisions, '') != '' THEN 20 ELSE 0 END +
|
|
2023
|
+
CASE WHEN COALESCE(pending, '') != '' THEN 15 ELSE 0 END +
|
|
2024
|
+
CASE WHEN COALESCE(context_next, '') != '' THEN 20 ELSE 0 END +
|
|
2025
|
+
CASE WHEN COALESCE(self_critique, '') != '' THEN 20 ELSE 0 END
|
|
2026
|
+
WHERE quality_score IS NULL OR quality_score = 0
|
|
2027
|
+
""")
|
|
2028
|
+
|
|
2029
|
+
|
|
2030
|
+
def _m65_diary_quality(conn):
|
|
2031
|
+
_m4_session_diary_columns(conn)
|
|
2032
|
+
_m7_diary_source_and_draft(conn)
|
|
2033
|
+
_m10_diary_archive(conn)
|
|
2034
|
+
_migrate_add_column(conn, "session_diary", "quality_tier", "TEXT DEFAULT 'agent_authored'")
|
|
2035
|
+
_migrate_add_column(conn, "session_diary", "quality_score", "INTEGER DEFAULT 0")
|
|
2036
|
+
_migrate_add_column(conn, "diary_archive", "quality_tier", "TEXT DEFAULT 'agent_authored'")
|
|
2037
|
+
_migrate_add_column(conn, "diary_archive", "quality_score", "INTEGER DEFAULT 0")
|
|
2038
|
+
_backfill_diary_quality(conn)
|
|
2039
|
+
_migrate_add_index(conn, "idx_session_diary_quality", "session_diary", "quality_tier, quality_score, created_at")
|
|
2040
|
+
_migrate_add_index(conn, "idx_diary_archive_quality", "diary_archive", "quality_tier, quality_score, created_at")
|
|
2041
|
+
|
|
2042
|
+
|
|
2043
|
+
def _m66_transcript_index(conn):
|
|
2044
|
+
conn.execute("""
|
|
2045
|
+
CREATE TABLE IF NOT EXISTS transcript_index (
|
|
2046
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2047
|
+
source_client TEXT NOT NULL,
|
|
2048
|
+
conversation_id TEXT DEFAULT '',
|
|
2049
|
+
session_id TEXT DEFAULT '',
|
|
2050
|
+
message_count INTEGER DEFAULT 0,
|
|
2051
|
+
user_message_count INTEGER DEFAULT 0,
|
|
2052
|
+
first_user_at TEXT DEFAULT '',
|
|
2053
|
+
last_user_at TEXT DEFAULT '',
|
|
2054
|
+
path_ref TEXT NOT NULL,
|
|
2055
|
+
display_name TEXT DEFAULT '',
|
|
2056
|
+
indexed_at TEXT DEFAULT (datetime('now')),
|
|
2057
|
+
modified_at TEXT DEFAULT '',
|
|
2058
|
+
content_hash TEXT NOT NULL,
|
|
2059
|
+
sanitized_summary TEXT DEFAULT '',
|
|
2060
|
+
metadata_json TEXT DEFAULT '{}',
|
|
2061
|
+
UNIQUE(source_client, path_ref)
|
|
2062
|
+
)
|
|
2063
|
+
""")
|
|
2064
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_transcript_index_client_modified ON transcript_index(source_client, modified_at)")
|
|
2065
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_transcript_index_session ON transcript_index(session_id)")
|
|
2066
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_transcript_index_conversation ON transcript_index(conversation_id)")
|
|
2067
|
+
|
|
2068
|
+
|
|
2069
|
+
def _m67_diary_quality_backfill_repair(conn):
|
|
2070
|
+
"""Repair DBs that already ran the original m65 default-only backfill."""
|
|
2071
|
+
_m4_session_diary_columns(conn)
|
|
2072
|
+
_m7_diary_source_and_draft(conn)
|
|
2073
|
+
_m10_diary_archive(conn)
|
|
2074
|
+
_migrate_add_column(conn, "session_diary", "quality_tier", "TEXT DEFAULT 'agent_authored'")
|
|
2075
|
+
_migrate_add_column(conn, "session_diary", "quality_score", "INTEGER DEFAULT 0")
|
|
2076
|
+
_migrate_add_column(conn, "diary_archive", "quality_tier", "TEXT DEFAULT 'agent_authored'")
|
|
2077
|
+
_migrate_add_column(conn, "diary_archive", "quality_score", "INTEGER DEFAULT 0")
|
|
2078
|
+
_backfill_diary_quality(conn)
|
|
2079
|
+
_migrate_add_index(conn, "idx_session_diary_quality", "session_diary", "quality_tier, quality_score, created_at")
|
|
2080
|
+
_migrate_add_index(conn, "idx_diary_archive_quality", "diary_archive", "quality_tier, quality_score, created_at")
|
|
2081
|
+
|
|
2082
|
+
|
|
2005
2083
|
MIGRATIONS = [
|
|
2006
2084
|
(1, "learnings_columns", _m1_learnings_columns),
|
|
2007
2085
|
(2, "followups_reasoning", _m2_followups_reasoning),
|
|
@@ -2067,6 +2145,9 @@ MIGRATIONS = [
|
|
|
2067
2145
|
(62, "memory_observations_fts_trigger_fix", _m62_memory_observations_fts_trigger_fix),
|
|
2068
2146
|
(63, "local_context_layer", _m63_local_context_layer),
|
|
2069
2147
|
(64, "local_context_live_dirs", _m64_local_context_live_dirs),
|
|
2148
|
+
(65, "diary_quality", _m65_diary_quality),
|
|
2149
|
+
(66, "transcript_index", _m66_transcript_index),
|
|
2150
|
+
(67, "diary_quality_backfill_repair", _m67_diary_quality_backfill_repair),
|
|
2070
2151
|
]
|
|
2071
2152
|
|
|
2072
2153
|
|
package/src/db/_skills.py
CHANGED
|
@@ -568,9 +568,7 @@ def retire_superseded_personal_skills(*, dry_run: bool = False) -> dict:
|
|
|
568
568
|
continue
|
|
569
569
|
|
|
570
570
|
if backup_root is None:
|
|
571
|
-
|
|
572
|
-
backup_root = paths.backups_dir() / f"retired-personal-skills-{timestamp}"
|
|
573
|
-
backup_root.mkdir(parents=True, exist_ok=True)
|
|
571
|
+
backup_root = paths.create_backup_dir("retired-personal-skills")
|
|
574
572
|
target = backup_root / child.name
|
|
575
573
|
suffix = 2
|
|
576
574
|
while target.exists():
|
|
@@ -588,6 +586,8 @@ def retire_superseded_personal_skills(*, dry_run: bool = False) -> dict:
|
|
|
588
586
|
})
|
|
589
587
|
except Exception as exc:
|
|
590
588
|
report["errors"].append({"path": str(child), "error": str(exc)})
|
|
589
|
+
if backup_root is not None:
|
|
590
|
+
paths.finalize_backup_snapshot(backup_root)
|
|
591
591
|
return report
|
|
592
592
|
|
|
593
593
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Post-disk-recovery self-heal hooks."""
|
|
2
|
+
|
|
3
|
+
from .registry import RecoveryContext, RecoveryRegistry, RecoveryResult, build_default_registry, run_sweep
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"RecoveryContext",
|
|
7
|
+
"RecoveryRegistry",
|
|
8
|
+
"RecoveryResult",
|
|
9
|
+
"build_default_registry",
|
|
10
|
+
"run_sweep",
|
|
11
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Platform handlers for disk recovery sweeps."""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Common app restart handlers used after disk pressure is relieved."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from disk_recovery.registry import RecoveryContext, RecoveryRegistry, RecoveryResult
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _has_process(ctx: RecoveryContext, *needles: str) -> bool:
|
|
8
|
+
lowered = {name.lower() for name in ctx.processes}
|
|
9
|
+
return any(needle.lower() in lowered for needle in needles)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _commands_for(ctx: RecoveryContext, app: str) -> list[list[str]]:
|
|
13
|
+
if ctx.platform == "darwin":
|
|
14
|
+
return [["killall", app], ["open", "-a", app]]
|
|
15
|
+
exe = f"{app}.exe" if not app.lower().endswith(".exe") else app
|
|
16
|
+
return [
|
|
17
|
+
["taskkill", "/IM", exe, "/F"],
|
|
18
|
+
["powershell", "-NoProfile", "-Command", f"Start-Process '{app}'"],
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _restart_if_running(ctx: RecoveryContext, app: str, *process_names: str) -> RecoveryResult:
|
|
23
|
+
if not _has_process(ctx, *process_names):
|
|
24
|
+
return RecoveryResult(app=app, touched=False, detail="not_running")
|
|
25
|
+
commands = _commands_for(ctx, app)
|
|
26
|
+
errors: list[str] = []
|
|
27
|
+
for command in commands:
|
|
28
|
+
result = ctx.run(command)
|
|
29
|
+
if not result.get("ok"):
|
|
30
|
+
errors.append(str(result.get("error") or result.get("stderr") or "command_failed"))
|
|
31
|
+
return RecoveryResult(app=app, touched=True, commands=commands, errors=errors)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register_common_handlers(registry: RecoveryRegistry) -> None:
|
|
35
|
+
registry.register("dropbox", {"darwin", "windows"}, lambda ctx: _restart_if_running(ctx, "Dropbox", "dropbox", "dropbox.exe"))
|
|
36
|
+
registry.register("google_drive", {"darwin", "windows"}, lambda ctx: _restart_if_running(ctx, "Google Drive", "google drive", "googledrivefs", "googledrivefs.exe"))
|
|
37
|
+
registry.register("slack", {"darwin", "windows"}, lambda ctx: _restart_if_running(ctx, "Slack", "slack", "slack.exe"))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""macOS sync handlers for post-disk-recovery sweep."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from disk_recovery.registry import RecoveryContext, RecoveryRegistry, RecoveryResult
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _run_commands(ctx: RecoveryContext, app: str, commands: list[list[str]]) -> RecoveryResult:
|
|
8
|
+
errors: list[str] = []
|
|
9
|
+
for command in commands:
|
|
10
|
+
result = ctx.run(command)
|
|
11
|
+
if not result.get("ok"):
|
|
12
|
+
errors.append(str(result.get("error") or result.get("stderr") or "command_failed"))
|
|
13
|
+
return RecoveryResult(app=app, touched=True, commands=commands, errors=errors)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def calendar_mail_handler(ctx: RecoveryContext) -> RecoveryResult:
|
|
17
|
+
commands = [
|
|
18
|
+
["killall", "CalendarAgent"],
|
|
19
|
+
["killall", "Calendar"],
|
|
20
|
+
["osascript", "-e", 'tell application "Mail" to check for new mail'],
|
|
21
|
+
]
|
|
22
|
+
return _run_commands(ctx, "macos_calendar_mail", commands)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def icloud_handler(ctx: RecoveryContext) -> RecoveryResult:
|
|
26
|
+
commands: list[list[str]] = []
|
|
27
|
+
processes = {name.lower() for name in ctx.processes}
|
|
28
|
+
if "cloudd" in processes:
|
|
29
|
+
commands.append(["killall", "cloudd"])
|
|
30
|
+
if "bird" in processes:
|
|
31
|
+
commands.append(["killall", "bird"])
|
|
32
|
+
if not commands:
|
|
33
|
+
return RecoveryResult(app="icloud_drive", touched=False, detail="not_running")
|
|
34
|
+
return _run_commands(ctx, "icloud_drive", commands)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def register_macos_handlers(registry: RecoveryRegistry) -> None:
|
|
38
|
+
registry.register("macos_calendar_mail", {"darwin"}, calendar_mail_handler)
|
|
39
|
+
registry.register("icloud_drive", {"darwin"}, icloud_handler)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Windows sync handlers for post-disk-recovery sweep."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from disk_recovery.registry import RecoveryContext, RecoveryRegistry, RecoveryResult
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _run_commands(ctx: RecoveryContext, app: str, commands: list[list[str]]) -> RecoveryResult:
|
|
8
|
+
errors: list[str] = []
|
|
9
|
+
for command in commands:
|
|
10
|
+
result = ctx.run(command)
|
|
11
|
+
if not result.get("ok"):
|
|
12
|
+
errors.append(str(result.get("error") or result.get("stderr") or "command_failed"))
|
|
13
|
+
return RecoveryResult(app=app, touched=True, commands=commands, errors=errors)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def onesync_handler(ctx: RecoveryContext) -> RecoveryResult:
|
|
17
|
+
commands = [
|
|
18
|
+
["powershell", "-NoProfile", "-Command", "Get-Service OneSyncSvc* | Restart-Service -Force"],
|
|
19
|
+
]
|
|
20
|
+
return _run_commands(ctx, "windows_onesync", commands)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def outlook_handler(ctx: RecoveryContext) -> RecoveryResult:
|
|
24
|
+
commands = [[
|
|
25
|
+
"powershell",
|
|
26
|
+
"-NoProfile",
|
|
27
|
+
"-Command",
|
|
28
|
+
"$o = New-Object -ComObject Outlook.Application; $o.Session.SendAndReceive($false)",
|
|
29
|
+
]]
|
|
30
|
+
return _run_commands(ctx, "outlook_send_receive", commands)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def onedrive_handler(ctx: RecoveryContext) -> RecoveryResult:
|
|
34
|
+
processes = {name.lower() for name in ctx.processes}
|
|
35
|
+
if "onedrive.exe" not in processes and "onedrive" not in processes:
|
|
36
|
+
return RecoveryResult(app="onedrive", touched=False, detail="not_running")
|
|
37
|
+
local = ctx.env.get("LOCALAPPDATA", r"%LOCALAPPDATA%")
|
|
38
|
+
exe = local + r"\Microsoft\OneDrive\OneDrive.exe"
|
|
39
|
+
commands = [
|
|
40
|
+
[exe, "/shutdown"],
|
|
41
|
+
["powershell", "-NoProfile", "-Command", f"Start-Process '{exe}'"],
|
|
42
|
+
]
|
|
43
|
+
return _run_commands(ctx, "onedrive", commands)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def register_windows_handlers(registry: RecoveryRegistry) -> None:
|
|
47
|
+
registry.register("windows_onesync", {"windows"}, onesync_handler)
|
|
48
|
+
registry.register("outlook_send_receive", {"windows"}, outlook_handler)
|
|
49
|
+
registry.register("onedrive", {"windows"}, onedrive_handler)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Extensible registry for post-disk-recovery app sync nudges."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import platform as platform_module
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
Runner = Callable[[list[str]], dict]
|
|
12
|
+
Handler = Callable[["RecoveryContext"], "RecoveryResult"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RecoveryContext:
|
|
17
|
+
platform: str
|
|
18
|
+
processes: set[str] = field(default_factory=set)
|
|
19
|
+
dry_run: bool = False
|
|
20
|
+
env: dict[str, str] = field(default_factory=lambda: dict(os.environ))
|
|
21
|
+
runner: Runner | None = None
|
|
22
|
+
|
|
23
|
+
def run(self, command: list[str]) -> dict:
|
|
24
|
+
if self.dry_run:
|
|
25
|
+
return {"ok": True, "dry_run": True, "command": command}
|
|
26
|
+
runner = self.runner or default_runner
|
|
27
|
+
return runner(command)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class RecoveryResult:
|
|
32
|
+
app: str
|
|
33
|
+
touched: bool
|
|
34
|
+
commands: list[list[str]] = field(default_factory=list)
|
|
35
|
+
errors: list[str] = field(default_factory=list)
|
|
36
|
+
detail: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def default_runner(command: list[str]) -> dict:
|
|
40
|
+
try:
|
|
41
|
+
proc = subprocess.run(command, capture_output=True, text=True, timeout=20)
|
|
42
|
+
return {
|
|
43
|
+
"ok": proc.returncode == 0,
|
|
44
|
+
"returncode": proc.returncode,
|
|
45
|
+
"stdout": proc.stdout[-1000:],
|
|
46
|
+
"stderr": proc.stderr[-1000:],
|
|
47
|
+
"command": command,
|
|
48
|
+
}
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
return {"ok": False, "error": str(exc), "command": command}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_processes(system: str | None = None) -> set[str]:
|
|
54
|
+
system = (system or platform_module.system()).lower()
|
|
55
|
+
command = ["tasklist"] if system == "windows" else ["ps", "-axo", "comm="]
|
|
56
|
+
try:
|
|
57
|
+
proc = subprocess.run(command, capture_output=True, text=True, timeout=5)
|
|
58
|
+
except Exception:
|
|
59
|
+
return set()
|
|
60
|
+
names: set[str] = set()
|
|
61
|
+
for raw in proc.stdout.splitlines():
|
|
62
|
+
name = _process_name_from_line(raw, windows=system == "windows")
|
|
63
|
+
if not name:
|
|
64
|
+
continue
|
|
65
|
+
names.add(name)
|
|
66
|
+
return names
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _process_name_from_line(raw: str, *, windows: bool) -> str:
|
|
70
|
+
line = str(raw or "").strip()
|
|
71
|
+
if not line:
|
|
72
|
+
return ""
|
|
73
|
+
if not windows:
|
|
74
|
+
return line.strip('"').rsplit("/", 1)[-1].lower()
|
|
75
|
+
lower = line.lower()
|
|
76
|
+
if lower.startswith("image name") or lower.startswith("="):
|
|
77
|
+
return ""
|
|
78
|
+
if line.startswith('"') and "," in line:
|
|
79
|
+
return line.split(",", 1)[0].strip().strip('"').lower()
|
|
80
|
+
return line.split(None, 1)[0].strip().strip('"').lower()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class RecoveryRegistry:
|
|
84
|
+
def __init__(self) -> None:
|
|
85
|
+
self._handlers: list[tuple[str, set[str], Handler]] = []
|
|
86
|
+
|
|
87
|
+
def register(self, name: str, platforms: set[str], handler: Handler) -> None:
|
|
88
|
+
self._handlers.append((name, {p.lower() for p in platforms}, handler))
|
|
89
|
+
|
|
90
|
+
def run(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
platform: str | None = None,
|
|
94
|
+
processes: set[str] | None = None,
|
|
95
|
+
dry_run: bool = False,
|
|
96
|
+
runner: Runner | None = None,
|
|
97
|
+
env: dict[str, str] | None = None,
|
|
98
|
+
) -> dict:
|
|
99
|
+
system = (platform or platform_module.system()).lower()
|
|
100
|
+
ctx = RecoveryContext(
|
|
101
|
+
platform=system,
|
|
102
|
+
processes=processes if processes is not None else default_processes(system),
|
|
103
|
+
dry_run=dry_run,
|
|
104
|
+
env=env or dict(os.environ),
|
|
105
|
+
runner=runner,
|
|
106
|
+
)
|
|
107
|
+
results: list[RecoveryResult] = []
|
|
108
|
+
for _name, platforms, handler in self._handlers:
|
|
109
|
+
if "all" not in platforms and system not in platforms:
|
|
110
|
+
continue
|
|
111
|
+
results.append(handler(ctx))
|
|
112
|
+
touched = [result.app for result in results if result.touched]
|
|
113
|
+
return {
|
|
114
|
+
"ok": not any(result.errors for result in results),
|
|
115
|
+
"platform": system,
|
|
116
|
+
"dry_run": dry_run,
|
|
117
|
+
"touched_apps": touched,
|
|
118
|
+
"results": [asdict(result) for result in results],
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_default_registry() -> RecoveryRegistry:
|
|
123
|
+
from .handlers.common import register_common_handlers
|
|
124
|
+
from .handlers.macos import register_macos_handlers
|
|
125
|
+
from .handlers.windows import register_windows_handlers
|
|
126
|
+
|
|
127
|
+
registry = RecoveryRegistry()
|
|
128
|
+
register_common_handlers(registry)
|
|
129
|
+
register_macos_handlers(registry)
|
|
130
|
+
register_windows_handlers(registry)
|
|
131
|
+
return registry
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def run_sweep(**kwargs) -> dict:
|
|
135
|
+
return build_default_registry().run(**kwargs)
|