ltcai 5.4.0 → 5.6.0

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,255 @@
1
+ """Brain review queue (5.6.0) — the suggestion inbox.
2
+
3
+ Automation/trigger runs drop drafts here; the user approves, dismisses, or
4
+ snoozes them. This service owns the *policy* (legal status transitions, snooze
5
+ expiry semantics, run_now back-linking); the store owns persistence.
6
+
7
+ Design decisions (agreed in #develop-with-openclaw):
8
+
9
+ * **run_now ≠ approve.** "Run now" is a preview/regenerate action: it executes
10
+ the underlying workflow but does **not** change ``status``. The fresh run id
11
+ is back-linked into ``payload.last_run_id`` / ``provenance.run_id`` and
12
+ ``updated_at`` is bumped. Accepting the result is a separate ``approve``.
13
+ * **Snooze expiry is read-time only.** A snoozed item whose ``snoozed_until``
14
+ has passed is surfaced with ``effective_status == "pending"``; the stored
15
+ ``status`` is left untouched (no scheduler mutation in 5.6.0).
16
+ * **Invalid transitions raise** :class:`InvalidReviewTransition` → HTTP 409.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from datetime import datetime
22
+ from typing import Any, Callable, Dict, List, Optional
23
+
24
+ # status: terminal vs. open. Open items can still be acted on.
25
+ OPEN_STATUSES = {"pending", "snoozed"}
26
+ TERMINAL_STATUSES = {"approved", "dismissed"}
27
+ ALL_STATUSES = OPEN_STATUSES | TERMINAL_STATUSES
28
+
29
+ REVIEW_SOURCES = frozenset({"workflow_run", "trigger", "kg_change_digest"})
30
+
31
+ # Which source statuses each action is allowed from.
32
+ _ALLOWED_FROM: Dict[str, set] = {
33
+ "approve": {"pending", "snoozed"},
34
+ "dismiss": {"pending", "snoozed"},
35
+ "snooze": {"pending", "snoozed"},
36
+ # run_now is a preview, not a transition — only while still open.
37
+ "run_now": {"pending", "snoozed"},
38
+ }
39
+
40
+
41
+ def review_queue_opted_in(workflow: Dict[str, Any]) -> bool:
42
+ """True when the workflow's trigger node opts into the review inbox."""
43
+ for node in workflow.get("nodes") or []:
44
+ if node.get("type") != "trigger":
45
+ continue
46
+ if (node.get("config") or {}).get("review_queue") is True:
47
+ return True
48
+ return False
49
+
50
+
51
+ class InvalidReviewTransition(Exception):
52
+ """Raised when an action is not legal from the item's current status."""
53
+
54
+ def __init__(self, action: str, status: str) -> None:
55
+ self.action = action
56
+ self.status = status
57
+ super().__init__(f"cannot {action!r} a review item in status {status!r}")
58
+
59
+
60
+ def _parse_iso(value: Optional[str]) -> Optional[datetime]:
61
+ if not value:
62
+ return None
63
+ try:
64
+ return datetime.fromisoformat(str(value))
65
+ except (TypeError, ValueError):
66
+ return None
67
+
68
+
69
+ class ReviewQueueService:
70
+ """Policy layer over the store's workspace-scoped ``review_items``."""
71
+
72
+ def __init__(self, *, store: Any, clock: Callable[[], datetime] = datetime.now) -> None:
73
+ self._store = store
74
+ self._clock = clock
75
+
76
+ # ── creation (also the review_sink entry point) ──────────────────────
77
+ def create(
78
+ self,
79
+ *,
80
+ title: str,
81
+ summary: str = "",
82
+ source: str = "workflow_run",
83
+ kind: str = "suggestion",
84
+ payload: Optional[Dict[str, Any]] = None,
85
+ provenance: Optional[Dict[str, Any]] = None,
86
+ user_email: Optional[str] = None,
87
+ workspace_id: Optional[str] = None,
88
+ ) -> Dict[str, Any]:
89
+ if source not in REVIEW_SOURCES:
90
+ raise ValueError(f"source must be one of {sorted(REVIEW_SOURCES)}")
91
+ item = self._store.create_review_item(
92
+ title=title,
93
+ summary=summary,
94
+ source=source,
95
+ kind=kind,
96
+ payload=payload,
97
+ provenance=provenance,
98
+ user_email=user_email,
99
+ workspace_id=workspace_id,
100
+ )
101
+ return self._view(item)
102
+
103
+ def list(
104
+ self, *, workspace_id: Optional[str] = None, user_email: Optional[str] = None,
105
+ status: Optional[str] = None, source: Optional[str] = None,
106
+ ) -> Dict[str, Any]:
107
+ items = [
108
+ self._view(it)
109
+ for it in self._store.list_review_items(
110
+ workspace_id=workspace_id, user_email=user_email, source=source,
111
+ )
112
+ ]
113
+ if status:
114
+ # Filter on the *effective* status so an expired snooze reads as pending.
115
+ items = [it for it in items if it["effective_status"] == status]
116
+ return {"items": items}
117
+
118
+ def get(self, item_id: str, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
119
+ return self._view(self._store.get_review_item(item_id, workspace_id=workspace_id))
120
+
121
+ # ── transitions ──────────────────────────────────────────────────────
122
+ def approve(self, item_id: str, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
123
+ return self._transition(item_id, "approve", "approved", workspace_id=workspace_id)
124
+
125
+ def dismiss(self, item_id: str, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
126
+ return self._transition(item_id, "dismiss", "dismissed", workspace_id=workspace_id)
127
+
128
+ def snooze(
129
+ self, item_id: str, *, until: str, workspace_id: Optional[str] = None,
130
+ ) -> Dict[str, Any]:
131
+ item = self._store.get_review_item(item_id, workspace_id=workspace_id)
132
+ self._guard("snooze", item)
133
+ updated = self._store.update_review_item(
134
+ item_id, workspace_id=workspace_id, status="snoozed", snoozed_until=until,
135
+ )
136
+ return self._view(updated)
137
+
138
+ # ── run_now: preview/regenerate, NOT a status change ─────────────────
139
+ def run_now(
140
+ self,
141
+ item_id: str,
142
+ *,
143
+ runner: Callable[[Dict[str, Any]], Any],
144
+ workspace_id: Optional[str] = None,
145
+ ) -> Dict[str, Any]:
146
+ """Execute the item's underlying workflow and back-link the new run.
147
+
148
+ ``status`` is intentionally left untouched — only ``payload.last_run_id``,
149
+ ``provenance.run_id`` and ``updated_at`` move. ``runner`` receives the raw
150
+ stored item and must return a run id (str) or a dict carrying one.
151
+ """
152
+ item = self._store.get_review_item(item_id, workspace_id=workspace_id)
153
+ self._guard("run_now", item)
154
+ run_id = _extract_run_id(runner(item))
155
+ payload = dict(item.get("payload") or {})
156
+ provenance = dict(item.get("provenance") or {})
157
+ if run_id is not None:
158
+ payload["last_run_id"] = run_id
159
+ provenance["run_id"] = run_id
160
+ updated = self._store.update_review_item(
161
+ item_id, workspace_id=workspace_id, payload=payload, provenance=provenance,
162
+ )
163
+ return self._view(updated)
164
+
165
+ # ── internals ────────────────────────────────────────────────────────
166
+ def _transition(
167
+ self, item_id: str, action: str, new_status: str, *, workspace_id: Optional[str],
168
+ ) -> Dict[str, Any]:
169
+ item = self._store.get_review_item(item_id, workspace_id=workspace_id)
170
+ self._guard(action, item)
171
+ patch: Dict[str, Any] = {"status": new_status}
172
+ # Leaving the snooze state clears the timer so it can't resurface later.
173
+ if item.get("snoozed_until") is not None:
174
+ patch["snoozed_until"] = None
175
+ updated = self._store.update_review_item(item_id, workspace_id=workspace_id, **patch)
176
+ return self._view(updated)
177
+
178
+ def _guard(self, action: str, item: Dict[str, Any]) -> None:
179
+ status = str(item.get("status") or "pending")
180
+ if status not in _ALLOWED_FROM.get(action, set()):
181
+ raise InvalidReviewTransition(action, status)
182
+
183
+ def _effective_status(self, item: Dict[str, Any]) -> str:
184
+ """Read-time view: an expired snooze reads as pending (no mutation)."""
185
+ if str(item.get("status")) != "snoozed":
186
+ return str(item.get("status") or "pending")
187
+ until = _parse_iso(item.get("snoozed_until"))
188
+ if until is not None and until <= self._clock():
189
+ return "pending"
190
+ return "snoozed"
191
+
192
+ def _view(self, item: Dict[str, Any]) -> Dict[str, Any]:
193
+ view = dict(item)
194
+ view["effective_status"] = self._effective_status(item)
195
+ return view
196
+
197
+
198
+ def enqueue_from_automation(
199
+ sink: "ReviewQueueService",
200
+ *,
201
+ workflow: Dict[str, Any],
202
+ source: str,
203
+ run_result: Any,
204
+ trigger_info: Optional[Dict[str, Any]] = None,
205
+ user_email: Optional[str] = None,
206
+ workspace_id: Optional[str] = None,
207
+ ) -> Optional[Dict[str, Any]]:
208
+ """Opt-in path: enqueue a review item when the workflow trigger requests it."""
209
+ if source not in REVIEW_SOURCES:
210
+ raise ValueError(f"source must be one of {sorted(REVIEW_SOURCES)}")
211
+ if not review_queue_opted_in(workflow):
212
+ return None
213
+ run_id = _extract_run_id(run_result)
214
+ wf_id = workflow.get("id")
215
+ provenance: Dict[str, Any] = {"workflow_id": wf_id}
216
+ if run_id is not None:
217
+ provenance["run_id"] = run_id
218
+ if trigger_info:
219
+ provenance["trigger_id"] = str(trigger_info.get("type") or "")
220
+ provenance["source_detail"] = str(trigger_info.get("type") or "")
221
+ return sink.create(
222
+ title=str(workflow.get("name") or "Automation suggestion"),
223
+ summary="",
224
+ source=source,
225
+ kind="suggestion",
226
+ payload={"workflow_id": wf_id},
227
+ provenance=provenance,
228
+ user_email=user_email,
229
+ workspace_id=workspace_id,
230
+ )
231
+
232
+
233
+ def _extract_run_id(result: Any) -> Optional[str]:
234
+ if result is None:
235
+ return None
236
+ if isinstance(result, str):
237
+ return result
238
+ if isinstance(result, dict):
239
+ if result.get("run_id"):
240
+ return str(result["run_id"])
241
+ run = result.get("run")
242
+ if isinstance(run, dict) and run.get("id"):
243
+ return str(run["id"])
244
+ if result.get("id"):
245
+ return str(result["id"])
246
+ return None
247
+
248
+
249
+ __all__ = [
250
+ "ReviewQueueService",
251
+ "InvalidReviewTransition",
252
+ "REVIEW_SOURCES",
253
+ "review_queue_opted_in",
254
+ "enqueue_from_automation",
255
+ ]
@@ -46,6 +46,7 @@ class RunExecutor:
46
46
  workspace_graph: Callable[[], Any],
47
47
  append_audit_event: Callable[..., None],
48
48
  hooks: Any = None,
49
+ review_sink: Any = None,
49
50
  ) -> None:
50
51
  self.store = store
51
52
  self.agent_runtime = agent_runtime
@@ -53,6 +54,8 @@ class RunExecutor:
53
54
  self.workspace_graph = workspace_graph
54
55
  self.append_audit_event = append_audit_event
55
56
  self.hooks = hooks
57
+ # Optional review-queue seam (5.6.0). Default None → no behavior change.
58
+ self.review_sink = review_sink
56
59
  self._handles: Dict[str, _RunHandle] = {}
57
60
  self._results: Dict[str, Dict[str, Any]] = {}
58
61
 
@@ -234,6 +237,12 @@ class RunExecutor:
234
237
  workflow_id=workflow.get("id"),
235
238
  status=result.status,
236
239
  )
240
+ self._maybe_enqueue_review(
241
+ workflow,
242
+ run_result={"run": updated, "result": result.as_dict()},
243
+ user_email=user_email,
244
+ workspace_id=handle.scope,
245
+ )
237
246
  self._results[run_id] = {"run": updated, "result": result.as_dict()}
238
247
  except Exception as exc:
239
248
  run = self.store.get_workflow_run(run_id, workspace_id=handle.scope)
@@ -252,6 +261,30 @@ class RunExecutor:
252
261
  finally:
253
262
  self._handles.pop(run_id, None)
254
263
 
264
+ def _maybe_enqueue_review(
265
+ self,
266
+ workflow: Dict[str, Any],
267
+ *,
268
+ run_result: Dict[str, Any],
269
+ user_email: Optional[str],
270
+ workspace_id: Optional[str],
271
+ ) -> None:
272
+ if self.review_sink is None:
273
+ return
274
+ try:
275
+ from latticeai.services.review_queue import enqueue_from_automation
276
+
277
+ enqueue_from_automation(
278
+ self.review_sink,
279
+ workflow=workflow,
280
+ source="workflow_run",
281
+ run_result=run_result,
282
+ user_email=user_email,
283
+ workspace_id=workspace_id,
284
+ )
285
+ except Exception:
286
+ pass
287
+
255
288
  def _execute_workflow_sync(
256
289
  self,
257
290
  workflow: Dict[str, Any],
@@ -48,9 +48,13 @@ class TriggerService:
48
48
  data_dir: Path,
49
49
  clock: Callable[[], float] = time.time,
50
50
  tick_seconds: float = DEFAULT_TICK_SECONDS,
51
+ review_sink: Optional[Any] = None,
51
52
  ) -> None:
52
53
  self._store = store
53
54
  self._run_workflow = run_workflow
55
+ # Optional review-queue seam (5.6.0). When wired, fired runs can drop a
56
+ # reviewable suggestion into the queue. Default None keeps legacy behavior.
57
+ self._review_sink = review_sink
54
58
  self._state_file = Path(data_dir) / "triggers_state.json"
55
59
  self._clock = clock
56
60
  self._tick = float(tick_seconds)
@@ -250,14 +254,39 @@ class TriggerService:
250
254
  def _fire(self, workflow_id: str, trigger_info: Dict[str, Any]) -> None:
251
255
  def _run():
252
256
  try:
253
- self._run_workflow(workflow_id, {"__trigger__": trigger_info})
257
+ result = self._run_workflow(workflow_id, {"__trigger__": trigger_info})
254
258
  self._record_fire_outcome(workflow_id, ok=True)
259
+ self._maybe_enqueue_review(workflow_id, trigger_info, result)
255
260
  except Exception as exc:
256
261
  logging.warning("trigger run failed for %s: %s", workflow_id, exc)
257
262
  self._record_fire_outcome(workflow_id, ok=False, detail=str(exc))
258
263
 
259
264
  threading.Thread(target=_run, name=f"trigger-{workflow_id}", daemon=True).start()
260
265
 
266
+ def _maybe_enqueue_review(
267
+ self, workflow_id: str, trigger_info: Dict[str, Any], result: Dict[str, Any],
268
+ ) -> None:
269
+ if self._review_sink is None:
270
+ return
271
+ try:
272
+ from latticeai.services.review_queue import enqueue_from_automation
273
+
274
+ workflows = list(self._store.load_state().get("workflows") or [])
275
+ workflow = next((wf for wf in workflows if wf.get("id") == workflow_id), None)
276
+ if workflow is None:
277
+ return
278
+ trigger_type = str(trigger_info.get("type") or "")
279
+ source = "kg_change_digest" if trigger_type == "brain_event" else "trigger"
280
+ enqueue_from_automation(
281
+ self._review_sink,
282
+ workflow=workflow,
283
+ source=source,
284
+ run_result=result,
285
+ trigger_info=trigger_info,
286
+ )
287
+ except Exception as exc:
288
+ logging.warning("review_sink enqueue failed for %s: %s", workflow_id, exc)
289
+
261
290
  def _record_fire_outcome(self, wf_id: str, *, ok: bool, detail: str = "") -> None:
262
291
  """Track consecutive launch failures for degraded status in describe().
263
292
  (Deep execution failures are visible via workflow run records; 여기서는 scheduler fire 자체 실패를 카운트.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "5.4.0",
3
+ "version": "5.6.0",
4
4
  "description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -1654,7 +1654,7 @@ dependencies = [
1654
1654
 
1655
1655
  [[package]]
1656
1656
  name = "lattice-ai-desktop"
1657
- version = "5.4.0"
1657
+ version = "5.6.0"
1658
1658
  dependencies = [
1659
1659
  "plist",
1660
1660
  "serde",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "lattice-ai-desktop"
3
- version = "5.4.0"
3
+ version = "5.6.0"
4
4
  description = "Lattice AI Digital Brain desktop shell"
5
5
  authors = ["TaeSoo Park"]
6
6
  edition = "2021"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://schema.tauri.app/config/2",
3
3
  "productName": "Lattice AI",
4
- "version": "5.4.0",
4
+ "version": "5.6.0",
5
5
  "identifier": "ai.lattice.desktop",
6
6
  "build": {
7
7
  "beforeDevCommand": "npm run frontend:dev",
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "5.4.0",
2
+ "version": "5.6.0",
3
3
  "generated_at": "vite",
4
4
  "entrypoints": {
5
5
  "app": "/static/app/index.html"
6
6
  },
7
7
  "assets": {
8
8
  "../node_modules/@tauri-apps/api/core.js": "/static/app/assets/core-CwxXejkd.js",
9
- "index.html": "/static/app/assets/index-C7vzwUjU.js",
10
- "assets/index-HN4f2wbe.css": "/static/app/assets/index-HN4f2wbe.css"
9
+ "index.html": "/static/app/assets/index-xMFu94cX.js",
10
+ "assets/index-xRn29gI8.css": "/static/app/assets/index-xRn29gI8.css"
11
11
  },
12
12
  "vite": {
13
13
  "../node_modules/@tauri-apps/api/core.js": {
@@ -17,7 +17,7 @@
17
17
  "isDynamicEntry": true
18
18
  },
19
19
  "index.html": {
20
- "file": "assets/index-C7vzwUjU.js",
20
+ "file": "assets/index-xMFu94cX.js",
21
21
  "name": "index",
22
22
  "src": "index.html",
23
23
  "isEntry": true,
@@ -25,7 +25,7 @@
25
25
  "../node_modules/@tauri-apps/api/core.js"
26
26
  ],
27
27
  "css": [
28
- "assets/index-HN4f2wbe.css"
28
+ "assets/index-xRn29gI8.css"
29
29
  ]
30
30
  }
31
31
  }