nexo-brain 7.30.19 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -2
- package/bin/nexo-brain.js +1 -1
- package/bin/nexo-managed-mcp.js +90 -0
- package/package.json +4 -2
- package/src/auto_update.py +16 -1
- package/src/client_sync.py +47 -0
- package/src/closure_plane.py +720 -0
- package/src/db/_schema.py +93 -0
- package/src/managed_mcp/__init__.py +31 -0
- package/src/managed_mcp/catalog.json +77 -0
- package/src/managed_mcp/catalog.py +263 -0
- package/src/managed_mcp/client_config.py +76 -0
- package/src/managed_mcp/lock.json +52 -0
- package/src/managed_mcp/reconcile.py +122 -0
- package/src/plugins/update.py +1 -0
- package/src/server.py +70 -0
- package/tool-enforcement-map.json +90 -0
|
@@ -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)
|