nexo-brain 3.0.0 → 3.0.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 +21 -1
- package/package.json +1 -1
- package/src/agent_runner.py +5 -1
- package/src/auto_update.py +23 -6
- package/src/client_preferences.py +5 -1
- package/src/client_sync.py +5 -1
- package/src/cognitive/_memory.py +14 -7
- package/src/cognitive/_search.py +12 -5
- package/src/crons/sync.py +2 -1
- package/src/dashboard/app.py +1 -1
- package/src/db/_workflow.py +2 -2
- package/src/doctor/models.py +25 -0
- package/src/doctor/orchestrator.py +32 -2
- package/src/doctor/providers/boot.py +52 -26
- package/src/doctor/providers/deep.py +24 -21
- package/src/doctor/providers/runtime.py +151 -135
- package/src/evolution_cycle.py +48 -46
- package/src/kg_populate.py +21 -19
- package/src/maintenance.py +3 -3
- package/src/migrate_embeddings.py +36 -34
- package/src/plugins/backup.py +24 -12
- package/src/plugins/schedule.py +13 -1
- package/src/plugins/update.py +18 -4
- package/src/public_contribution.py +10 -14
- package/src/requirements.txt +1 -0
- package/src/scripts/nexo-catchup.py +15 -15
- package/src/scripts/nexo-daily-self-audit.py +12 -1
- package/src/scripts/nexo-evolution-run.py +9 -3
- package/src/state_watchers_runtime.py +48 -41
- package/src/tools_sessions.py +2 -2
package/src/plugins/backup.py
CHANGED
|
@@ -21,10 +21,14 @@ def handle_backup_now() -> str:
|
|
|
21
21
|
# Use SQLite backup API for consistency
|
|
22
22
|
import sqlite3
|
|
23
23
|
src_conn = sqlite3.connect(DB_PATH)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
try:
|
|
25
|
+
dst_conn = sqlite3.connect(dest)
|
|
26
|
+
try:
|
|
27
|
+
src_conn.backup(dst_conn)
|
|
28
|
+
finally:
|
|
29
|
+
dst_conn.close()
|
|
30
|
+
finally:
|
|
31
|
+
src_conn.close()
|
|
28
32
|
|
|
29
33
|
size_kb = os.path.getsize(dest) / 1024
|
|
30
34
|
_cleanup_old()
|
|
@@ -63,17 +67,25 @@ def handle_backup_restore(filename: str) -> str:
|
|
|
63
67
|
safety = os.path.join(BACKUP_DIR, f"nexo-pre-restore-{time.strftime('%Y%m%d%H%M%S')}.db")
|
|
64
68
|
import sqlite3
|
|
65
69
|
src_conn = sqlite3.connect(DB_PATH)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
try:
|
|
71
|
+
dst_conn = sqlite3.connect(safety)
|
|
72
|
+
try:
|
|
73
|
+
src_conn.backup(dst_conn)
|
|
74
|
+
finally:
|
|
75
|
+
dst_conn.close()
|
|
76
|
+
finally:
|
|
77
|
+
src_conn.close()
|
|
70
78
|
|
|
71
79
|
# Restore
|
|
72
80
|
restore_conn = sqlite3.connect(src)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
try:
|
|
82
|
+
target_conn = sqlite3.connect(DB_PATH)
|
|
83
|
+
try:
|
|
84
|
+
restore_conn.backup(target_conn)
|
|
85
|
+
finally:
|
|
86
|
+
target_conn.close()
|
|
87
|
+
finally:
|
|
88
|
+
restore_conn.close()
|
|
77
89
|
|
|
78
90
|
# Invalidate shared connection so db.py reconnects to restored data
|
|
79
91
|
import db
|
package/src/plugins/schedule.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import platform
|
|
6
|
+
import re
|
|
6
7
|
import subprocess
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
@@ -69,9 +70,20 @@ def _summary_has_warning(summary: str = "") -> bool:
|
|
|
69
70
|
lowered = str(summary or "").strip().lower()
|
|
70
71
|
if not lowered:
|
|
71
72
|
return False
|
|
73
|
+
if re.search(r"\b[1-9]\d*\s+(errors?|warnings?)\b", lowered):
|
|
74
|
+
return True
|
|
72
75
|
if "no warning" in lowered or "without warnings" in lowered:
|
|
73
76
|
return False
|
|
74
|
-
warning_tokens = (
|
|
77
|
+
warning_tokens = (
|
|
78
|
+
"warning",
|
|
79
|
+
"warnings",
|
|
80
|
+
"warn:",
|
|
81
|
+
"degraded",
|
|
82
|
+
"partial failure",
|
|
83
|
+
"issues detected",
|
|
84
|
+
"completed with findings",
|
|
85
|
+
"findings detected",
|
|
86
|
+
)
|
|
75
87
|
return any(token in lowered for token in warning_tokens)
|
|
76
88
|
|
|
77
89
|
|
package/src/plugins/update.py
CHANGED
|
@@ -149,14 +149,21 @@ def _backup_databases() -> tuple[str, str | None]:
|
|
|
149
149
|
|
|
150
150
|
for db_file in db_files:
|
|
151
151
|
dest = backup_dir / db_file.name
|
|
152
|
+
src_conn = None
|
|
153
|
+
dst_conn = None
|
|
152
154
|
try:
|
|
153
155
|
src_conn = sqlite3.connect(str(db_file))
|
|
154
156
|
dst_conn = sqlite3.connect(str(dest))
|
|
155
157
|
src_conn.backup(dst_conn)
|
|
156
|
-
dst_conn.close()
|
|
157
|
-
src_conn.close()
|
|
158
158
|
except Exception as e:
|
|
159
159
|
return str(backup_dir), f"Failed to backup {db_file.name}: {e}"
|
|
160
|
+
finally:
|
|
161
|
+
for conn in (dst_conn, src_conn):
|
|
162
|
+
if conn is not None:
|
|
163
|
+
try:
|
|
164
|
+
conn.close()
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
160
167
|
|
|
161
168
|
return str(backup_dir), None
|
|
162
169
|
|
|
@@ -170,14 +177,21 @@ def _restore_databases(backup_dir: str):
|
|
|
170
177
|
# Try to find original location
|
|
171
178
|
for candidate in [DATA_DIR / db_backup.name, NEXO_HOME / db_backup.name, SRC_DIR / db_backup.name]:
|
|
172
179
|
if candidate.is_file():
|
|
180
|
+
src_conn = None
|
|
181
|
+
dst_conn = None
|
|
173
182
|
try:
|
|
174
183
|
src_conn = sqlite3.connect(str(db_backup))
|
|
175
184
|
dst_conn = sqlite3.connect(str(candidate))
|
|
176
185
|
src_conn.backup(dst_conn)
|
|
177
|
-
dst_conn.close()
|
|
178
|
-
src_conn.close()
|
|
179
186
|
except Exception:
|
|
180
187
|
pass
|
|
188
|
+
finally:
|
|
189
|
+
for conn in (dst_conn, src_conn):
|
|
190
|
+
if conn is not None:
|
|
191
|
+
try:
|
|
192
|
+
conn.close()
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
181
195
|
break
|
|
182
196
|
|
|
183
197
|
|
|
@@ -14,7 +14,7 @@ import re
|
|
|
14
14
|
import shutil
|
|
15
15
|
import socket
|
|
16
16
|
import subprocess
|
|
17
|
-
from datetime import datetime,
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
20
|
from runtime_power import load_schedule_config, save_schedule_config
|
|
@@ -33,8 +33,6 @@ STATUS_PENDING_AUTH = "pending_auth"
|
|
|
33
33
|
STATUS_PAUSED_OPEN_PR = "paused_open_pr"
|
|
34
34
|
STATUS_COOLDOWN = "cooldown"
|
|
35
35
|
STATUS_OFF = "off"
|
|
36
|
-
COOLDOWN_HOURS_MERGED = 168
|
|
37
|
-
COOLDOWN_HOURS_CLOSED = 72
|
|
38
36
|
VALID_MODES = {MODE_UNSET, MODE_OFF, MODE_DRAFT_PRS, MODE_PENDING_AUTH}
|
|
39
37
|
VALID_STATUSES = {
|
|
40
38
|
STATUS_UNSET,
|
|
@@ -224,10 +222,6 @@ def _parse_iso(ts: str | None) -> datetime | None:
|
|
|
224
222
|
return None
|
|
225
223
|
|
|
226
224
|
|
|
227
|
-
def _future_iso(hours: int) -> str:
|
|
228
|
-
return (_utcnow() + timedelta(hours=hours)).isoformat()
|
|
229
|
-
|
|
230
|
-
|
|
231
225
|
def format_public_contribution_label(config: dict | None = None) -> str:
|
|
232
226
|
cfg = normalize_public_contribution_config(config)
|
|
233
227
|
if cfg["mode"] == MODE_DRAFT_PRS:
|
|
@@ -369,12 +363,13 @@ def refresh_public_contribution_state(config: dict | None = None) -> dict:
|
|
|
369
363
|
config["status"] = STATUS_PAUSED_OPEN_PR
|
|
370
364
|
save_public_contribution_config(config)
|
|
371
365
|
return config
|
|
366
|
+
resolution = "merged" if payload.get("mergedAt") else "closed"
|
|
372
367
|
config["active_pr_url"] = ""
|
|
373
368
|
config["active_pr_number"] = None
|
|
374
369
|
config["active_branch"] = ""
|
|
375
|
-
|
|
376
|
-
config["
|
|
377
|
-
config["
|
|
370
|
+
config["cooldown_until"] = ""
|
|
371
|
+
config["status"] = STATUS_ACTIVE
|
|
372
|
+
config["last_result"] = f"resolved_pr:{resolution}:{payload.get('url') or ''}".rstrip(":")
|
|
378
373
|
save_public_contribution_config(config)
|
|
379
374
|
return config
|
|
380
375
|
return _set_pending_auth(
|
|
@@ -384,7 +379,11 @@ def refresh_public_contribution_state(config: dict | None = None) -> dict:
|
|
|
384
379
|
|
|
385
380
|
cooldown_until = _parse_iso(config.get("cooldown_until"))
|
|
386
381
|
if cooldown_until and cooldown_until > _utcnow():
|
|
387
|
-
|
|
382
|
+
# Legacy installs used a post-merge/close cooldown that blocked the next
|
|
383
|
+
# public contribution cycle even after maintainers resolved the Draft PR.
|
|
384
|
+
# Public contribution should pause only while the PR is still open.
|
|
385
|
+
config["cooldown_until"] = ""
|
|
386
|
+
config["status"] = STATUS_ACTIVE
|
|
388
387
|
save_public_contribution_config(config)
|
|
389
388
|
return config
|
|
390
389
|
|
|
@@ -430,9 +429,6 @@ def can_run_public_contribution(config: dict | None = None) -> tuple[bool, str,
|
|
|
430
429
|
return False, "public contribution is disabled", config
|
|
431
430
|
if config["status"] == STATUS_PAUSED_OPEN_PR:
|
|
432
431
|
return False, "an active Draft PR is already open for this machine", config
|
|
433
|
-
cooldown_until = _parse_iso(config.get("cooldown_until"))
|
|
434
|
-
if cooldown_until and cooldown_until > _utcnow():
|
|
435
|
-
return False, f"cooldown until {cooldown_until.isoformat()}", config
|
|
436
432
|
return True, "", config
|
|
437
433
|
|
|
438
434
|
|
package/src/requirements.txt
CHANGED
|
@@ -197,14 +197,14 @@ def main():
|
|
|
197
197
|
log("Catch-Up already running; skipping overlapping invocation.")
|
|
198
198
|
return
|
|
199
199
|
|
|
200
|
-
_heal_personal_schedules()
|
|
201
|
-
state = load_state()
|
|
202
|
-
tasks = catchup_candidates()
|
|
203
|
-
|
|
204
200
|
ran = 0
|
|
205
201
|
skipped = 0
|
|
206
202
|
skipped_out_of_window = 0
|
|
207
203
|
try:
|
|
204
|
+
_heal_personal_schedules()
|
|
205
|
+
state = load_state()
|
|
206
|
+
tasks = catchup_candidates()
|
|
207
|
+
|
|
208
208
|
for candidate in tasks:
|
|
209
209
|
name = candidate["cron_id"]
|
|
210
210
|
if not candidate.get("missed"):
|
|
@@ -221,19 +221,19 @@ def main():
|
|
|
221
221
|
log(f" {name} — missed scheduled run due at {due_at}, catching up...")
|
|
222
222
|
if run_task(candidate, state):
|
|
223
223
|
ran += 1
|
|
224
|
-
finally:
|
|
225
|
-
lock_handle.close()
|
|
226
224
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
225
|
+
if ran == 0 and skipped_out_of_window == 0:
|
|
226
|
+
log("All tasks up to date, nothing to catch up.")
|
|
227
|
+
elif ran >= 3:
|
|
228
|
+
# Many tasks caught up — ask CLI to assess system state
|
|
229
|
+
_cli_post_catchup_assessment(ran, skipped, state)
|
|
230
|
+
else:
|
|
231
|
+
suffix = f", {skipped_out_of_window} outside recovery window" if skipped_out_of_window else ""
|
|
232
|
+
log(f"Caught up {ran} tasks, {skipped} already current{suffix}.")
|
|
235
233
|
|
|
236
|
-
|
|
234
|
+
log("=== Catch-Up complete ===")
|
|
235
|
+
finally:
|
|
236
|
+
lock_handle.close()
|
|
237
237
|
|
|
238
238
|
|
|
239
239
|
def _cli_post_catchup_assessment(ran: int, skipped: int, state: dict):
|
|
@@ -1407,8 +1407,19 @@ def main():
|
|
|
1407
1407
|
except Exception:
|
|
1408
1408
|
pass
|
|
1409
1409
|
|
|
1410
|
+
if errors or warns:
|
|
1411
|
+
log(
|
|
1412
|
+
f"Self-audit completed with findings: {errors} errors, {warns} warnings, {infos} info. "
|
|
1413
|
+
f"Summary written to {summary_file}."
|
|
1414
|
+
)
|
|
1415
|
+
else:
|
|
1416
|
+
log(
|
|
1417
|
+
f"Self-audit completed cleanly: {errors} errors, {warns} warnings, {infos} info. "
|
|
1418
|
+
f"Summary written to {summary_file}."
|
|
1419
|
+
)
|
|
1420
|
+
|
|
1410
1421
|
log("=" * 60)
|
|
1411
|
-
return
|
|
1422
|
+
return 0
|
|
1412
1423
|
|
|
1413
1424
|
|
|
1414
1425
|
if __name__ == "__main__":
|
|
@@ -364,8 +364,6 @@ def _sanitize_public_diff(worktree_dir: Path, changed_files: list[str]) -> tuple
|
|
|
364
364
|
private_markers = [
|
|
365
365
|
str(Path.home()),
|
|
366
366
|
str(NEXO_HOME),
|
|
367
|
-
"/Users/",
|
|
368
|
-
"/home/",
|
|
369
367
|
"CLAUDE.md",
|
|
370
368
|
"AGENTS.md",
|
|
371
369
|
".nexo/",
|
|
@@ -374,6 +372,14 @@ def _sanitize_public_diff(worktree_dir: Path, changed_files: list[str]) -> tuple
|
|
|
374
372
|
for marker in private_markers:
|
|
375
373
|
if marker and marker in diff_text:
|
|
376
374
|
return False, f"Sanitization blocked private marker in diff: {marker}"
|
|
375
|
+
private_path_patterns = [
|
|
376
|
+
re.compile(r"/Users/[^/\s\"']+/"),
|
|
377
|
+
re.compile(r"/home/[^/\s\"']+/"),
|
|
378
|
+
]
|
|
379
|
+
for pattern in private_path_patterns:
|
|
380
|
+
match = pattern.search(diff_text)
|
|
381
|
+
if match:
|
|
382
|
+
return False, f"Sanitization blocked private path in diff: {match.group(0)}"
|
|
377
383
|
return True, ""
|
|
378
384
|
|
|
379
385
|
|
|
@@ -865,7 +871,7 @@ def run_public_contribution_cycle(*, objective: dict, cycle_num: int) -> None:
|
|
|
865
871
|
|
|
866
872
|
pr_url, pr_number = _create_draft_pr(worktree_dir, config, branch_name, summary)
|
|
867
873
|
artifact_dir = _write_public_artifacts(worktree_dir, branch_name, summary)
|
|
868
|
-
mark_active_pr(pr_url=pr_url, pr_number=pr_number, branch=branch_name, config=config)
|
|
874
|
+
config = mark_active_pr(pr_url=pr_url, pr_number=pr_number, branch=branch_name, config=config)
|
|
869
875
|
|
|
870
876
|
conn.execute(
|
|
871
877
|
"INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, reasoning, status, files_changed, test_result) "
|
|
@@ -5,7 +5,7 @@ import json
|
|
|
5
5
|
import os
|
|
6
6
|
import sqlite3
|
|
7
7
|
import subprocess
|
|
8
|
-
from datetime import
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from urllib import error, request
|
|
11
11
|
|
|
@@ -30,7 +30,7 @@ def _manifest_file() -> Path:
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def _now_iso() -> str:
|
|
33
|
-
return datetime.now(
|
|
33
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def _parse_dt(value: str | None) -> datetime | None:
|
|
@@ -41,13 +41,13 @@ def _parse_dt(value: str | None) -> datetime | None:
|
|
|
41
41
|
for candidate in (normalized, normalized + "T00:00:00+00:00"):
|
|
42
42
|
try:
|
|
43
43
|
parsed = datetime.fromisoformat(candidate)
|
|
44
|
-
return parsed if parsed.tzinfo else parsed.replace(tzinfo=
|
|
44
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
45
45
|
except Exception:
|
|
46
46
|
continue
|
|
47
47
|
for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S"):
|
|
48
48
|
try:
|
|
49
49
|
parsed = datetime.strptime(text, fmt)
|
|
50
|
-
return parsed.replace(tzinfo=
|
|
50
|
+
return parsed.replace(tzinfo=timezone.utc)
|
|
51
51
|
except Exception:
|
|
52
52
|
continue
|
|
53
53
|
return None
|
|
@@ -70,11 +70,15 @@ def _load_last_cron_run(cron_id: str) -> datetime | None:
|
|
|
70
70
|
if not db_path.is_file():
|
|
71
71
|
return None
|
|
72
72
|
conn = sqlite3.connect(str(db_path))
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
try:
|
|
74
|
+
row = conn.execute(
|
|
75
|
+
"SELECT started_at FROM cron_runs WHERE cron_id = ? ORDER BY started_at DESC LIMIT 1",
|
|
76
|
+
(cron_id,),
|
|
77
|
+
).fetchone()
|
|
78
|
+
except Exception:
|
|
79
|
+
return None
|
|
80
|
+
finally:
|
|
81
|
+
conn.close()
|
|
78
82
|
if not row or not row[0]:
|
|
79
83
|
return None
|
|
80
84
|
return _parse_dt(str(row[0]))
|
|
@@ -150,7 +154,7 @@ def _evaluate_cron_drift(watcher: dict) -> dict:
|
|
|
150
154
|
last_run = _load_last_cron_run(cron_id)
|
|
151
155
|
if not last_run:
|
|
152
156
|
return {"health": "critical", "summary": f"cron {cron_id} has never run", "evidence": [f"threshold_seconds={threshold}"]}
|
|
153
|
-
age = (datetime.now(
|
|
157
|
+
age = (datetime.now(timezone.utc) - last_run).total_seconds()
|
|
154
158
|
if age > threshold * 2:
|
|
155
159
|
health = "critical"
|
|
156
160
|
elif age > threshold:
|
|
@@ -218,7 +222,7 @@ def _evaluate_expiry(watcher: dict) -> dict:
|
|
|
218
222
|
return {"health": "critical", "summary": f"expiry watcher missing due_at: {watcher.get('watcher_id')}", "evidence": []}
|
|
219
223
|
warn_days = int(config.get("warn_days") or 21)
|
|
220
224
|
critical_days = int(config.get("critical_days") or 7)
|
|
221
|
-
remaining = due_at - datetime.now(
|
|
225
|
+
remaining = due_at - datetime.now(timezone.utc)
|
|
222
226
|
days = remaining.total_seconds() / 86400
|
|
223
227
|
if days <= critical_days:
|
|
224
228
|
health = "critical"
|
|
@@ -263,25 +267,26 @@ def _list_watchers(*, status: str) -> list[dict]:
|
|
|
263
267
|
if not db_path.is_file():
|
|
264
268
|
return []
|
|
265
269
|
conn = sqlite3.connect(str(db_path))
|
|
266
|
-
conn.row_factory = sqlite3.Row
|
|
267
|
-
clauses = []
|
|
268
|
-
params = []
|
|
269
|
-
if status:
|
|
270
|
-
clauses.append("status = ?")
|
|
271
|
-
params.append(status)
|
|
272
|
-
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
273
270
|
try:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
271
|
+
conn.row_factory = sqlite3.Row
|
|
272
|
+
clauses = []
|
|
273
|
+
params = []
|
|
274
|
+
if status:
|
|
275
|
+
clauses.append("status = ?")
|
|
276
|
+
params.append(status)
|
|
277
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
278
|
+
try:
|
|
279
|
+
rows = conn.execute(
|
|
280
|
+
f"""SELECT watcher_id, watcher_type, title, target, severity, status, config, last_health, last_result, last_checked_at
|
|
281
|
+
FROM state_watchers
|
|
282
|
+
{where}
|
|
283
|
+
ORDER BY updated_at DESC, watcher_id DESC""",
|
|
284
|
+
tuple(params),
|
|
285
|
+
).fetchall()
|
|
286
|
+
except sqlite3.OperationalError:
|
|
287
|
+
return []
|
|
288
|
+
finally:
|
|
282
289
|
conn.close()
|
|
283
|
-
return []
|
|
284
|
-
conn.close()
|
|
285
290
|
watchers = []
|
|
286
291
|
for row in rows:
|
|
287
292
|
watcher = dict(row)
|
|
@@ -298,19 +303,21 @@ def _persist_result(result: dict) -> None:
|
|
|
298
303
|
if not db_path.is_file():
|
|
299
304
|
return
|
|
300
305
|
conn = sqlite3.connect(str(db_path))
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
306
|
+
try:
|
|
307
|
+
conn.execute(
|
|
308
|
+
"""UPDATE state_watchers
|
|
309
|
+
SET last_health = ?, last_result = ?, last_checked_at = ?, updated_at = datetime('now')
|
|
310
|
+
WHERE watcher_id = ?""",
|
|
311
|
+
(
|
|
312
|
+
result["health"],
|
|
313
|
+
json.dumps(result, ensure_ascii=False),
|
|
314
|
+
result["checked_at"],
|
|
315
|
+
result["watcher_id"],
|
|
316
|
+
),
|
|
317
|
+
)
|
|
318
|
+
conn.commit()
|
|
319
|
+
finally:
|
|
320
|
+
conn.close()
|
|
314
321
|
|
|
315
322
|
|
|
316
323
|
def run_state_watchers(*, persist: bool = True, status: str = "active") -> dict:
|
package/src/tools_sessions.py
CHANGED
|
@@ -6,7 +6,7 @@ import os
|
|
|
6
6
|
import time
|
|
7
7
|
import secrets
|
|
8
8
|
import threading
|
|
9
|
-
from datetime import datetime,
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from db import (
|
|
12
12
|
register_session, update_session, complete_session,
|
|
@@ -133,7 +133,7 @@ def _session_portability_bundle(sid: str = "") -> dict:
|
|
|
133
133
|
]
|
|
134
134
|
return {
|
|
135
135
|
"ok": True,
|
|
136
|
-
"generated_at": datetime.now(
|
|
136
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
137
137
|
"session": {
|
|
138
138
|
"sid": session_id,
|
|
139
139
|
"task": session_row["task"],
|