nexo-brain 7.30.20 → 7.30.21

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.
@@ -0,0 +1,720 @@
1
+ from __future__ import annotations
2
+
3
+ """Operational Closure Plane MVP.
4
+
5
+ Read-only adapters discover unfinished operational work and project it into a
6
+ canonical closure_items table. Verification/close calls only mutate closure
7
+ metadata; they never execute the source action.
8
+ """
9
+
10
+ import datetime as _dt
11
+ import hashlib
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ OPEN_STATES = {"open", "waiting", "verified"}
19
+ FINAL_STATES = {"closed", "rejected", "stale"}
20
+
21
+
22
+ def _now_iso() -> str:
23
+ return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
24
+
25
+
26
+ def _today() -> str:
27
+ return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%d")
28
+
29
+
30
+ def _hash_id(prefix: str, value: str, length: int = 16) -> str:
31
+ digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]
32
+ return f"{prefix}-{digest}"
33
+
34
+
35
+ def _as_dict(row: Any) -> dict[str, Any]:
36
+ if row is None:
37
+ return {}
38
+ if hasattr(row, "keys"):
39
+ return {key: row[key] for key in row.keys()}
40
+ return dict(row)
41
+
42
+
43
+ def _table_exists(conn, table: str) -> bool:
44
+ row = conn.execute(
45
+ "SELECT name FROM sqlite_master WHERE type='table' AND name = ?",
46
+ (table,),
47
+ ).fetchone()
48
+ return row is not None
49
+
50
+
51
+ def _safe_json(payload: Any) -> str:
52
+ try:
53
+ return json.dumps(payload, ensure_ascii=False, sort_keys=True)
54
+ except Exception:
55
+ return "{}"
56
+
57
+
58
+ def _parse_time(value: Any) -> _dt.datetime | None:
59
+ if value is None or value == "":
60
+ return None
61
+ if isinstance(value, (int, float)):
62
+ try:
63
+ return _dt.datetime.fromtimestamp(float(value), tz=_dt.timezone.utc)
64
+ except Exception:
65
+ return None
66
+ text = str(value).strip()
67
+ if not text:
68
+ return None
69
+ if text.replace(".", "", 1).isdigit():
70
+ try:
71
+ return _dt.datetime.fromtimestamp(float(text), tz=_dt.timezone.utc)
72
+ except Exception:
73
+ return None
74
+ for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
75
+ try:
76
+ parsed = _dt.datetime.strptime(text[:19] if fmt != "%Y-%m-%d" else text[:10], fmt)
77
+ return parsed.replace(tzinfo=_dt.timezone.utc)
78
+ except Exception:
79
+ continue
80
+ try:
81
+ return _dt.datetime.fromisoformat(text.replace("Z", "+00:00"))
82
+ except Exception:
83
+ return None
84
+
85
+
86
+ def _age_days(value: Any) -> float:
87
+ parsed = _parse_time(value)
88
+ if not parsed:
89
+ return 0.0
90
+ return max(0.0, (_dt.datetime.now(_dt.timezone.utc) - parsed).total_seconds() / 86400)
91
+
92
+
93
+ def _deadline_urgency(value: Any, default: float = 0.35) -> float:
94
+ parsed = _parse_time(value)
95
+ if not parsed:
96
+ return default
97
+ delta_days = (parsed - _dt.datetime.now(_dt.timezone.utc)).total_seconds() / 86400
98
+ if delta_days <= 0:
99
+ return 1.0
100
+ if delta_days <= 1:
101
+ return 0.85
102
+ if delta_days <= 3:
103
+ return 0.7
104
+ if delta_days <= 7:
105
+ return 0.55
106
+ return 0.3
107
+
108
+
109
+ def _priority(impact: float, urgency: float, risk: float, confidence: float = 0.8) -> float:
110
+ score = (impact * 0.45) + (urgency * 0.35) + (confidence * 0.15) - (risk * 0.1)
111
+ return round(max(0.0, min(1.0, score)), 4)
112
+
113
+
114
+ def _candidate(
115
+ *,
116
+ source_primary: str,
117
+ source_key: str,
118
+ kind: str,
119
+ title: str,
120
+ summary: str = "",
121
+ state: str = "open",
122
+ impact: float = 0.6,
123
+ urgency: float = 0.4,
124
+ risk: float = 0.15,
125
+ confidence: float = 0.8,
126
+ safety_class: str = "normal",
127
+ capability_required: str = "",
128
+ capability_status: str = "unknown",
129
+ next_action: str = "",
130
+ blocker_reason: str = "",
131
+ evidence_required: str = "",
132
+ deadline_at: str = "",
133
+ source_payload: dict[str, Any] | None = None,
134
+ ) -> dict[str, Any]:
135
+ clean_source = str(source_primary)
136
+ clean_key = str(source_key)
137
+ dedupe_key = f"{clean_source}:{kind}:{clean_key}"
138
+ now = _now_iso()
139
+ return {
140
+ "id": _hash_id("CI", dedupe_key),
141
+ "title": str(title or clean_key)[:240],
142
+ "summary": str(summary or "")[:1200],
143
+ "kind": kind,
144
+ "state": state if state in OPEN_STATES else "open",
145
+ "source_primary": clean_source,
146
+ "source_key": clean_key,
147
+ "dedupe_key": dedupe_key,
148
+ "impact_score": round(impact, 4),
149
+ "urgency_score": round(urgency, 4),
150
+ "risk_score": round(risk, 4),
151
+ "confidence_score": round(confidence, 4),
152
+ "priority_score": _priority(impact, urgency, risk, confidence),
153
+ "safety_class": safety_class,
154
+ "capability_required": capability_required,
155
+ "capability_status": capability_status,
156
+ "owner": "nero",
157
+ "next_action": next_action,
158
+ "blocker_reason": blocker_reason,
159
+ "evidence_required": evidence_required,
160
+ "deadline_at": str(deadline_at or ""),
161
+ "first_seen_at": now,
162
+ "last_seen_at": now,
163
+ "source_payload_json": _safe_json(source_payload or {}),
164
+ }
165
+
166
+
167
+ def _upsert_candidate(conn, item: dict[str, Any]) -> bool:
168
+ existing = conn.execute(
169
+ "SELECT id, state FROM closure_items WHERE dedupe_key = ?",
170
+ (item["dedupe_key"],),
171
+ ).fetchone()
172
+ was_new = existing is None
173
+ conn.execute(
174
+ """
175
+ INSERT INTO closure_items (
176
+ id, title, summary, kind, state, source_primary, source_key,
177
+ dedupe_key, impact_score, urgency_score, risk_score,
178
+ confidence_score, priority_score, safety_class,
179
+ capability_required, capability_status, owner, next_action,
180
+ blocker_reason, evidence_required, deadline_at,
181
+ first_seen_at, last_seen_at, source_payload_json, updated_at
182
+ ) VALUES (
183
+ :id, :title, :summary, :kind, :state, :source_primary, :source_key,
184
+ :dedupe_key, :impact_score, :urgency_score, :risk_score,
185
+ :confidence_score, :priority_score, :safety_class,
186
+ :capability_required, :capability_status, :owner, :next_action,
187
+ :blocker_reason, :evidence_required, :deadline_at,
188
+ :first_seen_at, :last_seen_at, :source_payload_json, :last_seen_at
189
+ )
190
+ ON CONFLICT(dedupe_key) DO UPDATE SET
191
+ title = excluded.title,
192
+ summary = excluded.summary,
193
+ kind = excluded.kind,
194
+ source_primary = excluded.source_primary,
195
+ source_key = excluded.source_key,
196
+ impact_score = excluded.impact_score,
197
+ urgency_score = excluded.urgency_score,
198
+ risk_score = excluded.risk_score,
199
+ confidence_score = excluded.confidence_score,
200
+ priority_score = excluded.priority_score,
201
+ safety_class = excluded.safety_class,
202
+ capability_required = excluded.capability_required,
203
+ capability_status = excluded.capability_status,
204
+ next_action = excluded.next_action,
205
+ blocker_reason = excluded.blocker_reason,
206
+ evidence_required = excluded.evidence_required,
207
+ deadline_at = excluded.deadline_at,
208
+ last_seen_at = excluded.last_seen_at,
209
+ source_payload_json = excluded.source_payload_json,
210
+ updated_at = excluded.last_seen_at
211
+ WHERE closure_items.state NOT IN ('closed', 'rejected', 'stale')
212
+ """,
213
+ item,
214
+ )
215
+ source_id = _hash_id("CIS", f"{item['id']}:{item['source_primary']}:{item['source_key']}", 20)
216
+ conn.execute(
217
+ """
218
+ INSERT OR REPLACE INTO closure_item_sources (
219
+ id, closure_item_id, source_type, source_id, source_status,
220
+ source_payload_json, observed_at
221
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
222
+ """,
223
+ (
224
+ source_id,
225
+ item["id"],
226
+ item["source_primary"],
227
+ item["source_key"],
228
+ item["state"],
229
+ item["source_payload_json"],
230
+ item["last_seen_at"],
231
+ ),
232
+ )
233
+ if was_new:
234
+ _record_event(conn, item["id"], "discovered", "", item["state"], "Closure item discovered from source.")
235
+ return was_new
236
+
237
+
238
+ def _record_event(conn, item_id: str, event_type: str, from_state: str, to_state: str, note: str, evidence: str = "") -> None:
239
+ event_id = _hash_id("CIE", f"{item_id}:{event_type}:{from_state}:{to_state}:{_now_iso()}:{note}", 24)
240
+ conn.execute(
241
+ """
242
+ INSERT INTO closure_item_events (
243
+ id, closure_item_id, event_type, from_state, to_state, note, evidence, actor
244
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'nexo')
245
+ """,
246
+ (event_id, item_id, event_type, from_state, to_state, note, evidence),
247
+ )
248
+
249
+
250
+ def _protocol_task_candidates(conn, limit: int) -> list[dict[str, Any]]:
251
+ if not _table_exists(conn, "protocol_tasks"):
252
+ return []
253
+ rows = conn.execute(
254
+ """
255
+ SELECT task_id, session_id, goal, task_type, area, status, opened_at,
256
+ close_evidence, outcome_notes, verification_step
257
+ FROM protocol_tasks
258
+ WHERE lower(status) IN ('open', 'partial', 'blocked')
259
+ ORDER BY opened_at DESC
260
+ LIMIT ?
261
+ """,
262
+ (limit,),
263
+ ).fetchall()
264
+ items: list[dict[str, Any]] = []
265
+ for raw in rows:
266
+ row = _as_dict(raw)
267
+ status = str(row.get("status") or "open").lower()
268
+ state = "waiting" if status == "blocked" else "open"
269
+ urgency = min(1.0, 0.35 + (_age_days(row.get("opened_at")) / 14.0))
270
+ kind = f"protocol_task_{status}"
271
+ next_action = "Finish the task and close it with concrete evidence."
272
+ if status == "partial":
273
+ next_action = "Supply missing verification or convert the residual work into an explicit followup."
274
+ elif status == "blocked":
275
+ next_action = "Resolve the blocker or get an operator decision before continuing."
276
+ items.append(_candidate(
277
+ source_primary="protocol_tasks",
278
+ source_key=str(row.get("task_id") or ""),
279
+ kind=kind,
280
+ title=str(row.get("goal") or row.get("task_id") or "Open protocol task"),
281
+ summary=str(row.get("outcome_notes") or row.get("verification_step") or ""),
282
+ state=state,
283
+ impact=0.75,
284
+ urgency=urgency,
285
+ risk=0.15,
286
+ confidence=0.9,
287
+ next_action=next_action,
288
+ blocker_reason="status=blocked" if status == "blocked" else "",
289
+ evidence_required=str(row.get("verification_step") or "Evidence before closure."),
290
+ source_payload={
291
+ "status": row.get("status"),
292
+ "session_id": row.get("session_id"),
293
+ "task_type": row.get("task_type"),
294
+ "area": row.get("area"),
295
+ },
296
+ ))
297
+ return items
298
+
299
+
300
+ def _followup_candidates(conn, limit: int) -> list[dict[str, Any]]:
301
+ if not _table_exists(conn, "followups"):
302
+ return []
303
+ rows = conn.execute(
304
+ """
305
+ SELECT id, date, description, verification, status, impact_score, created_at, updated_at
306
+ FROM followups
307
+ WHERE lower(status) NOT IN ('done', 'complete', 'completed', 'deleted', 'archived', 'cancelled', 'canceled')
308
+ ORDER BY COALESCE(date, ''), updated_at DESC
309
+ LIMIT ?
310
+ """,
311
+ (limit,),
312
+ ).fetchall()
313
+ items: list[dict[str, Any]] = []
314
+ for raw in rows:
315
+ row = _as_dict(raw)
316
+ due = row.get("date") or ""
317
+ urgency = _deadline_urgency(due, default=0.45)
318
+ overdue = bool(_parse_time(due) and urgency >= 1.0)
319
+ kind = "followup_due" if overdue else "followup_pending"
320
+ items.append(_candidate(
321
+ source_primary="followups",
322
+ source_key=str(row.get("id") or ""),
323
+ kind=kind,
324
+ title=str(row.get("description") or row.get("id") or "Pending followup"),
325
+ state="open",
326
+ impact=max(0.45, min(1.0, float(row.get("impact_score") or 0) / 100.0 if row.get("impact_score") else 0.55)),
327
+ urgency=urgency,
328
+ risk=0.1,
329
+ confidence=0.85,
330
+ next_action="Complete, update, or explicitly reschedule this followup.",
331
+ evidence_required=str(row.get("verification") or "Result note or explicit reschedule evidence."),
332
+ deadline_at=str(due or ""),
333
+ source_payload={"status": row.get("status"), "date": due},
334
+ ))
335
+ return items
336
+
337
+
338
+ def _protocol_debt_candidates(conn, limit: int) -> list[dict[str, Any]]:
339
+ if not _table_exists(conn, "protocol_debt"):
340
+ return []
341
+ rows = conn.execute(
342
+ """
343
+ SELECT id, session_id, task_id, debt_type, severity, status, evidence, created_at
344
+ FROM protocol_debt
345
+ WHERE lower(status) = 'open'
346
+ ORDER BY created_at DESC
347
+ LIMIT ?
348
+ """,
349
+ (limit,),
350
+ ).fetchall()
351
+ items: list[dict[str, Any]] = []
352
+ severity_impact = {"block": 0.85, "error": 0.8, "warn": 0.6, "info": 0.35}
353
+ for raw in rows:
354
+ row = _as_dict(raw)
355
+ severity = str(row.get("severity") or "warn").lower()
356
+ impact = severity_impact.get(severity, 0.6)
357
+ items.append(_candidate(
358
+ source_primary="protocol_debt",
359
+ source_key=str(row.get("id") or ""),
360
+ kind="protocol_debt_open",
361
+ title=f"Open protocol debt: {row.get('debt_type') or row.get('id')}",
362
+ summary=str(row.get("evidence") or ""),
363
+ state="waiting" if severity in {"block", "error"} else "open",
364
+ impact=impact,
365
+ urgency=min(1.0, 0.4 + (_age_days(row.get("created_at")) / 21.0)),
366
+ risk=0.2,
367
+ confidence=0.9,
368
+ next_action="Resolve the debt with evidence or mark it as intentionally superseded.",
369
+ blocker_reason="blocking protocol debt" if severity in {"block", "error"} else "",
370
+ evidence_required="Resolution note tied to the source task/session.",
371
+ source_payload={
372
+ "severity": severity,
373
+ "task_id": row.get("task_id"),
374
+ "session_id": row.get("session_id"),
375
+ },
376
+ ))
377
+ return items
378
+
379
+
380
+ def _outcome_candidates(conn, limit: int) -> list[dict[str, Any]]:
381
+ if not _table_exists(conn, "outcomes"):
382
+ return []
383
+ rows = conn.execute(
384
+ """
385
+ SELECT id, action_type, action_id, session_id, description, expected_result,
386
+ status, deadline, checked_at, notes
387
+ FROM outcomes
388
+ WHERE lower(status) IN ('pending', 'missed', 'failed')
389
+ ORDER BY deadline ASC
390
+ LIMIT ?
391
+ """,
392
+ (limit,),
393
+ ).fetchall()
394
+ items: list[dict[str, Any]] = []
395
+ for raw in rows:
396
+ row = _as_dict(raw)
397
+ status = str(row.get("status") or "pending").lower()
398
+ items.append(_candidate(
399
+ source_primary="outcomes",
400
+ source_key=str(row.get("id") or ""),
401
+ kind=f"outcome_{status}",
402
+ title=str(row.get("description") or row.get("expected_result") or f"Outcome {row.get('id')}"),
403
+ summary=str(row.get("notes") or row.get("expected_result") or ""),
404
+ state="open",
405
+ impact=0.7 if status == "pending" else 0.8,
406
+ urgency=_deadline_urgency(row.get("deadline"), default=0.45),
407
+ risk=0.15,
408
+ confidence=0.85,
409
+ next_action="Verify the expected result and record the outcome.",
410
+ evidence_required=str(row.get("expected_result") or "Measured result or explicit miss reason."),
411
+ deadline_at=str(row.get("deadline") or ""),
412
+ source_payload={
413
+ "status": status,
414
+ "action_type": row.get("action_type"),
415
+ "action_id": row.get("action_id"),
416
+ "session_id": row.get("session_id"),
417
+ },
418
+ ))
419
+ return items
420
+
421
+
422
+ def _mcp_write_queue_candidates(limit: int) -> list[dict[str, Any]]:
423
+ nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))).expanduser()
424
+ root = nexo_home / "runtime" / "operations" / "mcp-write-queue"
425
+ if not root.exists():
426
+ return []
427
+ items: list[dict[str, Any]] = []
428
+ for state in ("dead_letter", "failed", "retrying", "queued"):
429
+ for path in sorted((root / state).glob("*.json"))[: max(1, limit)]:
430
+ if len(items) >= limit:
431
+ return items
432
+ try:
433
+ payload = json.loads(path.read_text(encoding="utf-8"))
434
+ except Exception:
435
+ continue
436
+ write_id = str(payload.get("writeId") or path.stem)
437
+ status = str(payload.get("status") or state)
438
+ created = payload.get("created_at")
439
+ items.append(_candidate(
440
+ source_primary="mcp_write_queue",
441
+ source_key=write_id,
442
+ kind=f"mcp_write_queue_{status}",
443
+ title=f"MCP write queue item {write_id}",
444
+ summary=str(payload.get("kind") or ""),
445
+ state="waiting" if status in {"failed", "dead_letter"} else "open",
446
+ impact=0.65,
447
+ urgency=min(1.0, 0.35 + (_age_days(created) / 3.0)),
448
+ risk=0.2,
449
+ confidence=0.8,
450
+ next_action="Inspect the queued write and let the queue worker retry or resolve it manually.",
451
+ blocker_reason=status if status in {"failed", "dead_letter"} else "",
452
+ evidence_required="Committed queue status or explicit dead-letter resolution.",
453
+ source_payload={
454
+ "status": status,
455
+ "kind": payload.get("kind"),
456
+ "attempts": payload.get("attempts"),
457
+ "last_error": str(payload.get("last_error") or "")[:240],
458
+ },
459
+ ))
460
+ return items
461
+
462
+
463
+ def refresh_closure_items(conn=None, *, limit_per_adapter: int = 250) -> dict[str, Any]:
464
+ if conn is None:
465
+ from db import get_db
466
+ from db._schema import run_migrations
467
+
468
+ conn = get_db()
469
+ run_migrations(conn)
470
+ candidates: list[dict[str, Any]] = []
471
+ adapter_counts: dict[str, int] = {}
472
+ adapters = [
473
+ ("protocol_tasks", lambda: _protocol_task_candidates(conn, limit_per_adapter)),
474
+ ("followups", lambda: _followup_candidates(conn, limit_per_adapter)),
475
+ ("protocol_debt", lambda: _protocol_debt_candidates(conn, limit_per_adapter)),
476
+ ("outcomes", lambda: _outcome_candidates(conn, limit_per_adapter)),
477
+ ("mcp_write_queue", lambda: _mcp_write_queue_candidates(limit_per_adapter)),
478
+ ]
479
+ for name, adapter in adapters:
480
+ try:
481
+ produced = adapter()
482
+ except Exception:
483
+ produced = []
484
+ adapter_counts[name] = len(produced)
485
+ candidates.extend(produced)
486
+
487
+ created = 0
488
+ for item in candidates:
489
+ if _upsert_candidate(conn, item):
490
+ created += 1
491
+ conn.commit()
492
+ _write_daily_snapshot(conn)
493
+ return {
494
+ "ok": True,
495
+ "adapters": adapter_counts,
496
+ "observed": len(candidates),
497
+ "created": created,
498
+ }
499
+
500
+
501
+ def closure_next(conn=None, *, limit: int = 10, include_waiting: bool = False, source: str = "", kind: str = "") -> list[dict[str, Any]]:
502
+ if conn is None:
503
+ from db import get_db
504
+
505
+ conn = get_db()
506
+ states = ("open", "verified", "waiting") if include_waiting else ("open", "verified")
507
+ clauses = [f"state IN ({','.join('?' for _ in states)})"]
508
+ params: list[Any] = list(states)
509
+ if source:
510
+ clauses.append("source_primary = ?")
511
+ params.append(source)
512
+ if kind:
513
+ clauses.append("kind = ?")
514
+ params.append(kind)
515
+ params.append(max(1, min(int(limit or 10), 100)))
516
+ rows = conn.execute(
517
+ f"""
518
+ SELECT *
519
+ FROM closure_items
520
+ WHERE {' AND '.join(clauses)}
521
+ ORDER BY priority_score DESC, urgency_score DESC, updated_at DESC
522
+ LIMIT ?
523
+ """,
524
+ params,
525
+ ).fetchall()
526
+ return [_as_dict(row) for row in rows]
527
+
528
+
529
+ def closure_status(conn=None, *, refresh: bool = True, limit: int = 10) -> dict[str, Any]:
530
+ if conn is None:
531
+ from db import get_db
532
+ from db._schema import run_migrations
533
+
534
+ conn = get_db()
535
+ run_migrations(conn)
536
+ refresh_result = refresh_closure_items(conn, limit_per_adapter=250) if refresh else {"ok": True}
537
+ counts = {
538
+ row["state"]: row["n"]
539
+ for row in conn.execute("SELECT state, COUNT(*) AS n FROM closure_items GROUP BY state").fetchall()
540
+ }
541
+ by_kind = {
542
+ row["kind"]: row["n"]
543
+ for row in conn.execute("SELECT kind, COUNT(*) AS n FROM closure_items WHERE state IN ('open', 'waiting', 'verified') GROUP BY kind").fetchall()
544
+ }
545
+ return {
546
+ "ok": True,
547
+ "schema": "nexo.closure.status.v1",
548
+ "refreshed": refresh_result,
549
+ "counts": counts,
550
+ "open_total": sum(int(counts.get(state, 0)) for state in OPEN_STATES),
551
+ "by_kind": by_kind,
552
+ "next": closure_next(conn, limit=limit, include_waiting=True),
553
+ }
554
+
555
+
556
+ def closure_item_get(item_id: str, conn=None) -> dict[str, Any] | None:
557
+ if conn is None:
558
+ from db import get_db
559
+
560
+ conn = get_db()
561
+ clean_id = str(item_id or "").strip()
562
+ if not clean_id:
563
+ return None
564
+ item = conn.execute(
565
+ "SELECT * FROM closure_items WHERE id = ? OR dedupe_key = ?",
566
+ (clean_id, clean_id),
567
+ ).fetchone()
568
+ if not item:
569
+ return None
570
+ payload = _as_dict(item)
571
+ sources = conn.execute(
572
+ "SELECT * FROM closure_item_sources WHERE closure_item_id = ? ORDER BY observed_at DESC",
573
+ (payload["id"],),
574
+ ).fetchall()
575
+ events = conn.execute(
576
+ "SELECT * FROM closure_item_events WHERE closure_item_id = ? ORDER BY created_at DESC LIMIT 50",
577
+ (payload["id"],),
578
+ ).fetchall()
579
+ payload["sources"] = [_as_dict(row) for row in sources]
580
+ payload["events"] = [_as_dict(row) for row in events]
581
+ return payload
582
+
583
+
584
+ def closure_verify_item(item_id: str, evidence: str, conn=None) -> dict[str, Any]:
585
+ if conn is None:
586
+ from db import get_db
587
+
588
+ conn = get_db()
589
+ clean_evidence = str(evidence or "").strip()
590
+ if not clean_evidence:
591
+ return {"ok": False, "error": "evidence is required"}
592
+ item = closure_item_get(item_id, conn)
593
+ if not item:
594
+ return {"ok": False, "error": "closure item not found"}
595
+ if item["state"] in FINAL_STATES:
596
+ return {"ok": False, "error": f"closure item is already {item['state']}"}
597
+ now = _now_iso()
598
+ conn.execute(
599
+ """
600
+ UPDATE closure_items
601
+ SET state = 'verified',
602
+ evidence_observed = ?,
603
+ last_progress_at = ?,
604
+ updated_at = ?
605
+ WHERE id = ?
606
+ """,
607
+ (clean_evidence, now, now, item["id"]),
608
+ )
609
+ _record_event(conn, item["id"], "verified", item["state"], "verified", "Closure evidence recorded.", clean_evidence)
610
+ conn.commit()
611
+ return {"ok": True, "id": item["id"], "state": "verified"}
612
+
613
+
614
+ def closure_close_item(item_id: str, *, reason: str = "completed", conn=None) -> dict[str, Any]:
615
+ if conn is None:
616
+ from db import get_db
617
+
618
+ conn = get_db()
619
+ item = closure_item_get(item_id, conn)
620
+ if not item:
621
+ return {"ok": False, "error": "closure item not found"}
622
+ if item["state"] in FINAL_STATES:
623
+ return {"ok": True, "id": item["id"], "state": item["state"], "already_final": True}
624
+ if not str(item.get("evidence_observed") or "").strip() and str(reason or "").strip() not in {"rejected", "stale"}:
625
+ return {"ok": False, "error": "verification evidence is required before close"}
626
+ final_state = "rejected" if str(reason or "").strip() == "rejected" else "stale" if str(reason or "").strip() == "stale" else "closed"
627
+ now = _now_iso()
628
+ conn.execute(
629
+ """
630
+ UPDATE closure_items
631
+ SET state = ?,
632
+ closed_at = ?,
633
+ close_reason = ?,
634
+ updated_at = ?
635
+ WHERE id = ?
636
+ """,
637
+ (final_state, now, str(reason or final_state), now, item["id"]),
638
+ )
639
+ _record_event(conn, item["id"], "closed", item["state"], final_state, str(reason or final_state), item.get("evidence_observed") or "")
640
+ conn.commit()
641
+ return {"ok": True, "id": item["id"], "state": final_state}
642
+
643
+
644
+ def _write_daily_snapshot(conn) -> None:
645
+ counts = {
646
+ row["state"]: row["n"]
647
+ for row in conn.execute("SELECT state, COUNT(*) AS n FROM closure_items GROUP BY state").fetchall()
648
+ }
649
+ top = closure_next(conn, limit=10, include_waiting=True)
650
+ conn.execute(
651
+ """
652
+ INSERT OR REPLACE INTO closure_daily_snapshots (
653
+ snapshot_date, total_open, total_verified, total_waiting, total_closed,
654
+ top_items_json, created_at
655
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
656
+ """,
657
+ (
658
+ _today(),
659
+ int(counts.get("open", 0)),
660
+ int(counts.get("verified", 0)),
661
+ int(counts.get("waiting", 0)),
662
+ int(counts.get("closed", 0)),
663
+ _safe_json([
664
+ {
665
+ "id": item.get("id"),
666
+ "title": item.get("title"),
667
+ "priority_score": item.get("priority_score"),
668
+ "state": item.get("state"),
669
+ }
670
+ for item in top
671
+ ]),
672
+ _now_iso(),
673
+ ),
674
+ )
675
+ conn.commit()
676
+
677
+
678
+ def handle_closure_status(refresh: bool = True, limit: int = 10) -> str:
679
+ return json.dumps(closure_status(refresh=refresh, limit=limit), indent=2, ensure_ascii=False)
680
+
681
+
682
+ def handle_closure_next(limit: int = 10, include_waiting: bool = False, source: str = "", kind: str = "") -> str:
683
+ from db import get_db
684
+ from db._schema import run_migrations
685
+
686
+ conn = get_db()
687
+ run_migrations(conn)
688
+ refresh_closure_items(conn)
689
+ return json.dumps({
690
+ "ok": True,
691
+ "items": closure_next(conn, limit=limit, include_waiting=include_waiting, source=source, kind=kind),
692
+ }, indent=2, ensure_ascii=False)
693
+
694
+
695
+ def handle_closure_item_get(item_id: str) -> str:
696
+ from db import get_db
697
+ from db._schema import run_migrations
698
+
699
+ conn = get_db()
700
+ run_migrations(conn)
701
+ item = closure_item_get(item_id, conn)
702
+ return json.dumps({"ok": bool(item), "item": item}, indent=2, ensure_ascii=False)
703
+
704
+
705
+ def handle_closure_verify(item_id: str, evidence: str) -> str:
706
+ from db import get_db
707
+ from db._schema import run_migrations
708
+
709
+ conn = get_db()
710
+ run_migrations(conn)
711
+ return json.dumps(closure_verify_item(item_id, evidence, conn), indent=2, ensure_ascii=False)
712
+
713
+
714
+ def handle_closure_close(item_id: str, reason: str = "completed") -> str:
715
+ from db import get_db
716
+ from db._schema import run_migrations
717
+
718
+ conn = get_db()
719
+ run_migrations(conn)
720
+ return json.dumps(closure_close_item(item_id, reason=reason, conn=conn), indent=2, ensure_ascii=False)