ltcai 9.9.2 → 9.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +44 -39
  2. package/docs/CHANGELOG.md +109 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +47 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/curator.py +141 -0
  14. package/lattice_brain/graph/discovery_index.py +8 -1
  15. package/lattice_brain/graph/documents.py +111 -0
  16. package/lattice_brain/graph/fusion.py +179 -0
  17. package/lattice_brain/graph/ingest.py +43 -9
  18. package/lattice_brain/graph/projection.py +382 -37
  19. package/lattice_brain/graph/retrieval.py +71 -4
  20. package/lattice_brain/graph/retrieval_policy.py +174 -0
  21. package/lattice_brain/graph/retrieval_vector.py +122 -2
  22. package/lattice_brain/ingestion.py +66 -0
  23. package/lattice_brain/portability.py +41 -12
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/automation_intelligence.py +159 -0
  27. package/latticeai/api/browser.py +15 -4
  28. package/latticeai/api/chat.py +33 -2
  29. package/latticeai/api/chat_agent_http.py +363 -6
  30. package/latticeai/api/chat_contracts.py +7 -1
  31. package/latticeai/api/chat_helpers.py +147 -0
  32. package/latticeai/api/chat_intents.py +217 -10
  33. package/latticeai/api/chat_stream.py +93 -3
  34. package/latticeai/api/funnel_metrics.py +31 -0
  35. package/latticeai/api/knowledge_graph.py +65 -1
  36. package/latticeai/api/local_files.py +90 -0
  37. package/latticeai/api/setup.py +134 -1
  38. package/latticeai/api/tools.py +33 -3
  39. package/latticeai/app_factory.py +14 -0
  40. package/latticeai/core/agent.py +355 -18
  41. package/latticeai/core/agent_eval.py +107 -0
  42. package/latticeai/core/enterprise.py +5 -0
  43. package/latticeai/core/file_generation.py +404 -2
  44. package/latticeai/core/legacy_compatibility.py +1 -1
  45. package/latticeai/core/marketplace.py +1 -1
  46. package/latticeai/core/run_store.py +243 -0
  47. package/latticeai/core/workspace_os.py +1 -1
  48. package/latticeai/models/router.py +25 -12
  49. package/latticeai/runtime/persistence_runtime.py +19 -1
  50. package/latticeai/runtime/router_registration.py +8 -1
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/automation_execution.py +266 -0
  54. package/latticeai/services/automation_intelligence.py +7 -0
  55. package/latticeai/services/command_center.py +110 -2
  56. package/latticeai/services/folder_watch.py +396 -0
  57. package/latticeai/services/funnel_metrics.py +210 -0
  58. package/latticeai/services/product_readiness.py +1 -1
  59. package/latticeai/services/search_service.py +51 -5
  60. package/latticeai/setup/demo_corpus.py +98 -0
  61. package/latticeai/tools/filesystem.py +38 -1
  62. package/package.json +1 -1
  63. package/scripts/bench_agent_smoke.py +410 -0
  64. package/scripts/bench_models.py +234 -5
  65. package/scripts/check_current_release_docs.mjs +1 -1
  66. package/scripts/funnel_soft_gate.py +192 -0
  67. package/src-tauri/Cargo.lock +1 -1
  68. package/src-tauri/Cargo.toml +1 -1
  69. package/src-tauri/tauri.conf.json +1 -1
  70. package/static/app/asset-manifest.json +29 -27
  71. package/static/app/assets/Act-BCmTU0E2.js +1 -0
  72. package/static/app/assets/{Brain-BnGbytHa.js → Brain-CTnjox7w.js} +2 -2
  73. package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
  74. package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
  75. package/static/app/assets/{Capture-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
  76. package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
  77. package/static/app/assets/Library-pKCK0_tk.js +1 -0
  78. package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
  79. package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
  80. package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
  81. package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
  82. package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
  83. package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
  84. package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
  85. package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
  86. package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
  87. package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
  88. package/static/app/assets/index-7FAfYm4v.css +2 -0
  89. package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
  90. package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
  91. package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
  92. package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
  93. package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
  94. package/static/app/assets/shield-alert-BftATuAA.js +1 -0
  95. package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
  96. package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
  97. package/static/app/assets/utils-SJUNVOj5.js +7 -0
  98. package/static/app/index.html +4 -4
  99. package/static/sw.js +1 -1
  100. package/static/app/assets/Act-Cx_gZNbE.js +0 -1
  101. package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
  102. package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
  103. package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
  104. package/static/app/assets/Library-PcfVUco6.js +0 -1
  105. package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
  106. package/static/app/assets/download-C7yxYHvz.js +0 -1
  107. package/static/app/assets/index-DU78Kogz.css +0 -2
  108. package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
  109. package/static/app/assets/utils-Dh-Dwtkb.js +0 -7
@@ -0,0 +1,243 @@
1
+ """Durable persistence for paused agent runs (review 2026-07-25 Wave 0.1).
2
+
3
+ The ``awaiting_approval`` loop parked runs in an in-process dict, so a server
4
+ restart between pause and resume turned every outstanding approval into a 404
5
+ ("승인하려다 사라짐"). This store writes each paused run to one JSON file under
6
+ the data dir, so a valid, unexpired token can resume its run across restarts
7
+ and across worker processes.
8
+
9
+ Design constraints (deliberate, matching :mod:`latticeai.services.funnel_metrics`):
10
+
11
+ * **One JSON file per run**, atomic replace on write — no new dependencies.
12
+ * **Fail-open on save, fail-closed on resume.** A persistence error never
13
+ breaks the pause response (the in-memory path still works); a load error on
14
+ resume is treated as "run not found".
15
+ * **Tokens are never stored in plaintext.** Only the SHA-256 hex digest is
16
+ persisted; resume hashes the presented token and compares digests.
17
+ * **Wall-clock expiry.** The in-memory dict keeps its monotonic deadline, but
18
+ monotonic time does not survive a restart — the durable record carries an
19
+ absolute UTC epoch and files past it are swept, never resumed.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import logging
27
+ import os
28
+ import re
29
+ import tempfile
30
+ import time
31
+ from pathlib import Path
32
+ from typing import Any, Dict, List, Optional
33
+
34
+ from latticeai.core.agent import AgentRunContext, AgentState
35
+ from latticeai.core.agent_trace import LoopTrace
36
+
37
+ LOGGER = logging.getLogger(__name__)
38
+
39
+ # run_id comes from ``secrets.token_urlsafe`` — validate before any path use
40
+ # so a crafted id can never traverse out of the store directory.
41
+ _RUN_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
42
+
43
+
44
+ def hash_approval_token(token: str) -> str:
45
+ """Stable digest used both at save and at resume comparison time."""
46
+ return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()
47
+
48
+
49
+ def serialize_run_context(ctx: AgentRunContext) -> Dict[str, Any]:
50
+ """Reduce an :class:`AgentRunContext` to plain JSON-safe data."""
51
+ return {
52
+ "state": ctx.state.value,
53
+ "plan": ctx.plan,
54
+ "transcript": ctx.transcript,
55
+ "retry_count": ctx.retry_count,
56
+ "state_history": ctx.state_history,
57
+ "corrections": ctx.corrections,
58
+ "final_message": ctx.final_message,
59
+ "rollback_log": ctx.rollback_log,
60
+ "executing_model": ctx.executing_model,
61
+ "reviewing_model": ctx.reviewing_model,
62
+ "approved_by_human": ctx.approved_by_human,
63
+ "trace": {"events": ctx.trace.events, "truncated": ctx.trace.truncated},
64
+ }
65
+
66
+
67
+ def restore_run_context(payload: Dict[str, Any]) -> AgentRunContext:
68
+ """Rebuild an :class:`AgentRunContext` from :func:`serialize_run_context`."""
69
+ ctx = AgentRunContext()
70
+ try:
71
+ ctx.state = AgentState(str(payload.get("state") or AgentState.WAITING_APPROVAL.value))
72
+ except ValueError:
73
+ ctx.state = AgentState.WAITING_APPROVAL
74
+ ctx.plan = dict(payload.get("plan") or {})
75
+ ctx.transcript = list(payload.get("transcript") or [])
76
+ ctx.retry_count = int(payload.get("retry_count") or 0)
77
+ ctx.state_history = list(payload.get("state_history") or [])
78
+ ctx.corrections = list(payload.get("corrections") or [])
79
+ ctx.final_message = str(payload.get("final_message") or "")
80
+ ctx.rollback_log = list(payload.get("rollback_log") or [])
81
+ ctx.executing_model = payload.get("executing_model")
82
+ ctx.reviewing_model = payload.get("reviewing_model")
83
+ ctx.approved_by_human = bool(payload.get("approved_by_human"))
84
+ trace_payload = payload.get("trace") or {}
85
+ trace = LoopTrace()
86
+ trace.events = list(trace_payload.get("events") or [])
87
+ trace.truncated = int(trace_payload.get("truncated") or 0)
88
+ ctx.trace = trace
89
+ return ctx
90
+
91
+
92
+ class AgentRunStore:
93
+ """One-JSON-file-per-run persistence for paused approval runs."""
94
+
95
+ def __init__(self, root: Path) -> None:
96
+ self._root = Path(root)
97
+
98
+ def _path_for(self, run_id: str) -> Optional[Path]:
99
+ if not _RUN_ID_RE.fullmatch(str(run_id or "")):
100
+ return None
101
+ return self._root / f"{run_id}.json"
102
+
103
+ # ── writes ───────────────────────────────────────────────────────────
104
+
105
+ def save(
106
+ self,
107
+ run_id: str,
108
+ *,
109
+ ctx: AgentRunContext,
110
+ req_payload: Dict[str, Any],
111
+ language_hint: str,
112
+ user: str,
113
+ token: str,
114
+ expires_epoch: float,
115
+ expires_at: str,
116
+ ) -> bool:
117
+ """Persist a paused run. Best-effort: returns False on any failure."""
118
+ path = self._path_for(run_id)
119
+ if path is None:
120
+ return False
121
+ record = {
122
+ "version": 1,
123
+ "run_id": run_id,
124
+ "user": user,
125
+ "language_hint": language_hint,
126
+ "token_hash": hash_approval_token(token),
127
+ "expires_epoch": float(expires_epoch),
128
+ "expires_at": expires_at,
129
+ "req": req_payload,
130
+ "ctx": serialize_run_context(ctx),
131
+ }
132
+ try:
133
+ self._root.mkdir(parents=True, exist_ok=True)
134
+ fd, tmp_name = tempfile.mkstemp(prefix=path.name, dir=str(self._root))
135
+ try:
136
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
137
+ json.dump(record, handle, ensure_ascii=False)
138
+ os.replace(tmp_name, path)
139
+ finally:
140
+ if os.path.exists(tmp_name):
141
+ try:
142
+ os.unlink(tmp_name)
143
+ except OSError:
144
+ pass
145
+ return True
146
+ except Exception as exc: # noqa: BLE001 — pause must still answer without disk
147
+ LOGGER.warning("agent run store save failed for %s: %s", run_id, exc)
148
+ return False
149
+
150
+ def delete(self, run_id: str) -> None:
151
+ path = self._path_for(run_id)
152
+ if path is None:
153
+ return
154
+ try:
155
+ path.unlink(missing_ok=True)
156
+ except OSError as exc:
157
+ LOGGER.warning("agent run store delete failed for %s: %s", run_id, exc)
158
+
159
+ # ── reads ────────────────────────────────────────────────────────────
160
+
161
+ def load(self, run_id: str) -> Optional[Dict[str, Any]]:
162
+ """Raw persisted record, or None when missing/corrupt/invalid id."""
163
+ path = self._path_for(run_id)
164
+ if path is None:
165
+ return None
166
+ try:
167
+ record = json.loads(path.read_text(encoding="utf-8"))
168
+ except FileNotFoundError:
169
+ return None
170
+ except Exception as exc: # noqa: BLE001 — corrupt record == not found
171
+ LOGGER.warning("agent run store load failed for %s: %s", run_id, exc)
172
+ return None
173
+ if not isinstance(record, dict) or record.get("run_id") != run_id:
174
+ return None
175
+ return record
176
+
177
+ def pending_summaries(self, user: Optional[str] = None) -> List[Dict[str, Any]]:
178
+ """Unexpired pending runs (optionally per user) for surfacing in UI."""
179
+ summaries: List[Dict[str, Any]] = []
180
+ now = time.time()
181
+ try:
182
+ paths = sorted(self._root.glob("*.json"))
183
+ except OSError:
184
+ return summaries
185
+ for path in paths:
186
+ try:
187
+ record = json.loads(path.read_text(encoding="utf-8"))
188
+ except Exception: # noqa: BLE001
189
+ continue
190
+ if not isinstance(record, dict):
191
+ continue
192
+ if float(record.get("expires_epoch") or 0) <= now:
193
+ continue
194
+ if user is not None and record.get("user") != user:
195
+ continue
196
+ plan = (record.get("ctx") or {}).get("plan") or {}
197
+ summaries.append({
198
+ "run_id": record.get("run_id"),
199
+ "user": record.get("user"),
200
+ "goal": str(plan.get("goal") or "")[:200],
201
+ "expires_at": record.get("expires_at"),
202
+ })
203
+ return summaries
204
+
205
+ def sweep_expired(
206
+ self,
207
+ now_epoch: Optional[float] = None,
208
+ *,
209
+ retention_seconds: float = 86400.0,
210
+ ) -> int:
211
+ """Remove long-expired run files; returns how many were removed.
212
+
213
+ Recently expired records are *kept* for ``retention_seconds`` so that
214
+ a resume attempt after expiry can still answer 410 with a one-click
215
+ replan hint (the original request message) instead of a bare 404.
216
+ """
217
+ removed = 0
218
+ now = time.time() if now_epoch is None else float(now_epoch)
219
+ try:
220
+ paths = list(self._root.glob("*.json"))
221
+ except OSError:
222
+ return removed
223
+ for path in paths:
224
+ try:
225
+ record = json.loads(path.read_text(encoding="utf-8"))
226
+ expires = float(record.get("expires_epoch") or 0)
227
+ except Exception: # noqa: BLE001 — unreadable == expired garbage
228
+ expires = -float(retention_seconds)
229
+ if expires + float(retention_seconds) <= now:
230
+ try:
231
+ path.unlink(missing_ok=True)
232
+ removed += 1
233
+ except OSError:
234
+ pass
235
+ return removed
236
+
237
+
238
+ __all__ = [
239
+ "AgentRunStore",
240
+ "hash_approval_token",
241
+ "restore_run_context",
242
+ "serialize_run_context",
243
+ ]
@@ -49,7 +49,7 @@ __all__ = [
49
49
  "remove_skill_directory",
50
50
  ]
51
51
 
52
- WORKSPACE_OS_VERSION = "9.9.2"
52
+ WORKSPACE_OS_VERSION = "9.9.4"
53
53
 
54
54
  # Workspace types separate single-user Personal workspaces from shared
55
55
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -78,6 +78,27 @@ If context or old chat history mentions those names, treat them only as legacy a
78
78
  You are a Vision-Language Model (VLM). If an image is provided, analyze it.
79
79
  Be concise and respond in the user's language."""
80
80
 
81
+ # Appended ONLY when retrieved context exists (review 2026-07-25 Wave 2.3):
82
+ # grounded answers should cite their sources and admit gaps. Advisory prompt
83
+ # guidance — grounding assessment stays annotation-only and never blocks.
84
+ CITATION_INSTRUCTION = """The Context section above contains retrieved sources.
85
+ Ground your claims in those sources and cite them inline as [1], [2], ... matching the order they appear in the Context.
86
+ If the context does not cover the question, say so instead of inventing sources.
87
+ Never cite a source that is not in the Context."""
88
+
89
+
90
+ def _compose_system(base: str, context: str) -> str:
91
+ """Compose the system prompt with optional retrieved context.
92
+
93
+ Byte-compatible with the historical prompt when ``context`` is empty:
94
+ the return value is exactly ``base``. When context exists, the Context
95
+ block plus :data:`CITATION_INSTRUCTION` are appended.
96
+ """
97
+ if not context:
98
+ return base
99
+ return f"{base}\n\nContext:\n{context}\n\n{CITATION_INSTRUCTION}"
100
+
101
+
81
102
  def normalize_branding(text: Optional[str]) -> str:
82
103
  if not text:
83
104
  return ""
@@ -497,10 +518,8 @@ class LLMRouter:
497
518
  return raw
498
519
 
499
520
  def _build_prompt(self, message: str, context: Optional[str], tokenizer) -> str:
500
- system = SYSTEM_PROMPT
501
521
  context = normalize_branding(context)
502
- if context:
503
- system += f"\n\nContext:\n{context}"
522
+ system = _compose_system(SYSTEM_PROMPT, context)
504
523
  if hasattr(tokenizer, "apply_chat_template"):
505
524
  try:
506
525
  msgs = [{"role": "system", "content": system}, {"role": "user", "content": message}]
@@ -510,10 +529,8 @@ class LLMRouter:
510
529
  return f"<|im_start|>system\n{system}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
511
530
 
512
531
  def _build_vlm_prompt(self, model, processor, message: str, context: Optional[str], num_images: int) -> str:
513
- system = SYSTEM_PROMPT
514
532
  context = normalize_branding(context)
515
- if context:
516
- system += f"\n\nContext:\n{context}"
533
+ system = _compose_system(SYSTEM_PROMPT, context)
517
534
  try:
518
535
  from mlx_vlm import apply_chat_template
519
536
 
@@ -616,10 +633,8 @@ class LLMRouter:
616
633
  return normalize_branding(str(result))
617
634
 
618
635
  async def _cloud_generate(self, cloud: CloudModel, message: str, context: Optional[str], max_tokens: int, temperature: float) -> str:
619
- system = SYSTEM_PROMPT
620
636
  context = normalize_branding(context)
621
- if context:
622
- system += f"\n\nContext:\n{context}"
637
+ system = _compose_system(SYSTEM_PROMPT, context)
623
638
  try:
624
639
  response = await cloud.client.chat.completions.create(
625
640
  model=cloud.model,
@@ -703,10 +718,8 @@ class LLMRouter:
703
718
  yield chunk
704
719
 
705
720
  async def _cloud_stream_generate(self, cloud: CloudModel, message: str, context: Optional[str], max_tokens: int, temperature: float) -> AsyncIterator[str]:
706
- system = SYSTEM_PROMPT
707
721
  context = normalize_branding(context)
708
- if context:
709
- system += f"\n\nContext:\n{context}"
722
+ system = _compose_system(SYSTEM_PROMPT, context)
710
723
  try:
711
724
  stream = await cloud.client.chat.completions.create(
712
725
  model=cloud.model,
@@ -38,6 +38,7 @@ def build_persistence_runtime(
38
38
  from latticeai.core.workspace_os import WorkspaceOSStore
39
39
  from latticeai.services.automation_intelligence import AutomationIntelligenceService
40
40
  from latticeai.services.brain_intelligence import BrainIntelligenceService
41
+ from latticeai.services.funnel_metrics import FunnelMetricsService
41
42
  from latticeai.services.memory_service import MemoryService
42
43
  from latticeai.services.workspace_service import WorkspaceService
43
44
 
@@ -70,11 +71,27 @@ def build_persistence_runtime(
70
71
  store=workspace_os,
71
72
  enable_graph=enable_graph,
72
73
  )
74
+ # UX funnel metrics (backlog #16): cheap JSON counters under the data dir.
75
+ # The ingestion pipeline's audit seam is wrapped so every successful
76
+ # ingest bumps the funnel (and starts the TTFV clock) without touching
77
+ # lattice_brain internals.
78
+ funnel_metrics = FunnelMetricsService(Path(data_dir) / "funnel_metrics.json")
79
+
80
+ def _funnel_audit(action, detail, user):
81
+ try:
82
+ if action == "kg_ingest":
83
+ funnel_metrics.record_ingest(
84
+ duplicate=bool((detail or {}).get("duplicate"))
85
+ )
86
+ except Exception: # noqa: BLE001 — metrics must never break ingestion
87
+ pass
88
+ audit(action, detail, user)
89
+
73
90
  ingestion_pipeline = IngestionPipeline(
74
91
  knowledge_graph,
75
92
  hooks=hooks_registry,
76
93
  enable_graph=enable_graph,
77
- audit=audit,
94
+ audit=_funnel_audit,
78
95
  )
79
96
  device_identity = DeviceIdentity(data_dir)
80
97
  kg_portability = KGPortabilityService(
@@ -99,4 +116,5 @@ def build_persistence_runtime(
99
116
  "INGESTION_PIPELINE": ingestion_pipeline,
100
117
  "DEVICE_IDENTITY": device_identity,
101
118
  "KG_PORTABILITY": kg_portability,
119
+ "FUNNEL_METRICS": funnel_metrics,
102
120
  }
@@ -605,6 +605,7 @@ def register_review_and_brain_tail_routers(
605
605
  create_setup_router: Any,
606
606
  model_router: Any,
607
607
  change_proposals: Any = None,
608
+ knowledge_graph: Any = None,
608
609
  ) -> Any:
609
610
  """Register the final review/browser/brain tail routes in legacy order."""
610
611
 
@@ -644,6 +645,12 @@ def register_review_and_brain_tail_routers(
644
645
  require_admin=require_admin,
645
646
  ),
646
647
  create_garden_router(gardener=gardener, require_user=require_user),
647
- create_setup_router(model_router=model_router, require_user=require_user),
648
+ create_setup_router(
649
+ model_router=model_router,
650
+ require_user=require_user,
651
+ ingestion_pipeline=ingestion_pipeline,
652
+ knowledge_graph=knowledge_graph,
653
+ workspace_service=workspace_service,
654
+ ),
648
655
  )
649
656
  return brain_network
@@ -42,6 +42,9 @@ class AppContext:
42
42
  hooks: Any = None
43
43
  realtime_bus: Any = None
44
44
  capability_registry: Any = None
45
+ # UX funnel metrics (latticeai.services.funnel_metrics); None in tests
46
+ # that don't observe the funnel — every increment site is nil-safe.
47
+ funnel_metrics: Any = None
45
48
 
46
49
  # ── auth / session callables ──────────────────────────────────────────
47
50
  require_user: Optional[Callable[..., str]] = None
@@ -17,7 +17,7 @@ from typing import Any, Dict, List
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
19
 
20
- ARCHITECTURE_VERSION_TARGET = "9.9.2"
20
+ ARCHITECTURE_VERSION_TARGET = "9.9.4"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",
@@ -0,0 +1,266 @@
1
+ """Automation "run now" support: dry-run reports + execution log surfacing.
2
+
3
+ Backlog #6 (review §7.2 F, Wave 2): after installing an automation the user
4
+ should be able to run it once immediately — first as a **dry run** that
5
+ reports exactly what WOULD happen without side effects, then for real — and
6
+ every automation card should show its last execution result.
7
+
8
+ This module owns the deterministic policy pieces:
9
+
10
+ * :func:`dry_run_report` — walks the workflow's node graph (same normalizer
11
+ as the engine) and describes each step without invoking any runner. No
12
+ model call, no store write, no side effects.
13
+ * :func:`build_last_execution` / :func:`summarize_workflow_run` — the compact
14
+ ``last_execution`` record persisted on the workflow's metadata and surfaced
15
+ by the automation overview + daily briefing.
16
+ * :func:`last_execution_view` — read-side merge: the stamped metadata record
17
+ vs. the newest persisted workflow run (so trigger-driven runs surface too).
18
+ * :func:`enqueue_failed_execution` — failed live executions become a review
19
+ queue item (source ``workflow_run``, kind ``automation_failure``) so
20
+ failures land in the same inbox as every other automation outcome.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from typing import Any, Dict, List, Optional
27
+
28
+ from latticeai.core.timeutil import now_iso as _now
29
+
30
+ LOGGER = logging.getLogger(__name__)
31
+
32
+ AUTOMATION_CREATED_FROM = frozenset({
33
+ "automation_suggestion",
34
+ "brain_automation_recipe",
35
+ })
36
+
37
+ _TRIGGER_LABEL = {
38
+ "interval": "wait for the user-enabled schedule",
39
+ "brain_event": "wait for new Brain knowledge",
40
+ "manual": "start when the user asks",
41
+ }
42
+
43
+
44
+ def is_automation_workflow(workflow: Dict[str, Any]) -> bool:
45
+ metadata = (workflow or {}).get("metadata") or {}
46
+ return metadata.get("created_from") in AUTOMATION_CREATED_FROM
47
+
48
+
49
+ def dry_run_report(workflow: Dict[str, Any]) -> Dict[str, Any]:
50
+ """Describe what a run WOULD do — deterministically, with no side effects.
51
+
52
+ Walks the normalized node chain (``trigger`` first, then ``next``
53
+ pointers) and emits one human-readable step per node. Nothing is
54
+ executed: no runner, no model, no store write.
55
+ """
56
+ from lattice_brain.workflow import normalize_definition, validate_definition
57
+
58
+ definition = normalize_definition(workflow or {})
59
+ errors = validate_definition(definition)
60
+ metadata = definition.get("metadata") or {}
61
+ steps: List[Dict[str, Any]] = []
62
+
63
+ nodes = {node.get("id"): node for node in definition.get("nodes") or []}
64
+ current = next(
65
+ (node for node in definition.get("nodes") or [] if node.get("type") == "trigger"),
66
+ None,
67
+ )
68
+ visited: set = set()
69
+ while current is not None and current.get("id") not in visited:
70
+ visited.add(current.get("id"))
71
+ ntype = str(current.get("type") or "")
72
+ config = current.get("config") or {}
73
+ if ntype == "trigger":
74
+ would = _TRIGGER_LABEL.get(
75
+ str(config.get("trigger") or "manual"),
76
+ "start when triggered",
77
+ )
78
+ would = f"skipped in a manual run ({would})"
79
+ elif ntype == "agent":
80
+ prompt = str(config.get("prompt") or config.get("goal") or "")[:160]
81
+ would = f"ask the local agent to draft: {prompt}" if prompt else (
82
+ "ask the local agent to draft a review item"
83
+ )
84
+ elif ntype == "tool":
85
+ would = f"run tool '{config.get('tool') or current.get('name') or 'tool'}'"
86
+ elif ntype == "skill":
87
+ would = f"run skill '{config.get('skill') or current.get('name') or 'skill'}'"
88
+ elif ntype == "plugin":
89
+ would = f"run plugin '{config.get('plugin') or current.get('name') or 'plugin'}'"
90
+ elif ntype == "condition":
91
+ would = "evaluate a branch condition"
92
+ elif ntype == "output":
93
+ would = "deliver the draft to the review inbox"
94
+ else:
95
+ would = f"run node '{current.get('id')}'"
96
+ steps.append({
97
+ "node": current.get("id"),
98
+ "type": ntype,
99
+ "name": current.get("name") or current.get("id"),
100
+ "would": would,
101
+ })
102
+ next_id = current.get("next")
103
+ current = nodes.get(next_id) if next_id else None
104
+
105
+ executable = [s for s in steps if s["type"] not in {"trigger", "output", "condition"}]
106
+ creates = list(metadata.get("creates") or [])
107
+ if errors:
108
+ summary = "invalid workflow: " + "; ".join(errors[:3])
109
+ status = "invalid"
110
+ else:
111
+ summary = (
112
+ f"{len(executable)} step(s) would run locally and produce a reviewable draft"
113
+ )
114
+ if creates:
115
+ summary += " (" + ", ".join(str(c) for c in creates[:3]) + ")"
116
+ summary += "; no external actions, nothing is written until you approve."
117
+ status = "ok"
118
+ return {
119
+ "mode": "dry_run",
120
+ "status": status,
121
+ "steps": steps,
122
+ "summary": summary,
123
+ "side_effects": False,
124
+ "validation_errors": errors,
125
+ }
126
+
127
+
128
+ def build_last_execution(
129
+ *,
130
+ mode: str,
131
+ status: str,
132
+ summary: str,
133
+ run_id: Optional[str] = None,
134
+ ) -> Dict[str, Any]:
135
+ """The compact per-automation execution record (persisted on metadata)."""
136
+ return {
137
+ "mode": mode, # dry_run | live
138
+ "status": status, # ok | failed | running | partial | ...
139
+ "summary": str(summary or "")[:300],
140
+ "run_id": run_id,
141
+ "finished_at": _now(),
142
+ }
143
+
144
+
145
+ def summarize_workflow_run(run: Dict[str, Any]) -> str:
146
+ """One honest line for a persisted workflow run."""
147
+ status = str(run.get("status") or "unknown")
148
+ timeline = run.get("timeline") or []
149
+ if status == "failed":
150
+ detail = ""
151
+ for entry in reversed(timeline):
152
+ if entry.get("status") in {"failed", "error"}:
153
+ detail = str(
154
+ entry.get("reason") or entry.get("detail") or entry.get("errors") or ""
155
+ )[:160]
156
+ break
157
+ if not detail:
158
+ detail = str((run.get("outputs") or {}).get("error") or "")[:160]
159
+ return f"failed after {len(timeline)} step(s)" + (f": {detail}" if detail else "")
160
+ if status == "awaiting_approval":
161
+ return "paused — a step needs your approval before it can run"
162
+ if status in {"queued", "running", "cancelling"}:
163
+ return "still running"
164
+ return f"{status} — {len(timeline)} step(s) recorded"
165
+
166
+
167
+ def last_execution_view(
168
+ workflow: Dict[str, Any],
169
+ *,
170
+ store: Any = None,
171
+ workspace_id: Optional[str] = None,
172
+ ) -> Optional[Dict[str, Any]]:
173
+ """Read-side ``last_execution``: stamped metadata vs. newest persisted run.
174
+
175
+ Run-now stamps ``metadata.last_execution`` directly; trigger/background
176
+ runs only leave workflow-run rows. Whichever is newer wins so the card is
177
+ honest about the latest execution regardless of how it was started.
178
+ """
179
+ metadata = (workflow or {}).get("metadata") or {}
180
+ stamped = metadata.get("last_execution")
181
+ stamped = dict(stamped) if isinstance(stamped, dict) else None
182
+
183
+ latest_run: Optional[Dict[str, Any]] = None
184
+ if store is not None and hasattr(store, "list_workflow_runs"):
185
+ try:
186
+ runs = (
187
+ store.list_workflow_runs(
188
+ workflow_id=workflow.get("id"), limit=1, workspace_id=workspace_id
189
+ )
190
+ or {}
191
+ ).get("runs") or []
192
+ latest_run = runs[0] if runs else None
193
+ except Exception: # noqa: BLE001 — surfacing must never break the overview
194
+ LOGGER.exception("automation last-execution run read failed")
195
+ latest_run = None
196
+
197
+ derived: Optional[Dict[str, Any]] = None
198
+ if latest_run is not None:
199
+ derived = {
200
+ "mode": "live",
201
+ "status": str(latest_run.get("status") or "unknown"),
202
+ "summary": summarize_workflow_run(latest_run),
203
+ "run_id": latest_run.get("id"),
204
+ "finished_at": str(
205
+ latest_run.get("completed_at")
206
+ or latest_run.get("updated_at")
207
+ or latest_run.get("created_at")
208
+ or ""
209
+ ),
210
+ }
211
+
212
+ if stamped is None:
213
+ return derived
214
+ if derived is None:
215
+ return stamped
216
+ return derived if str(derived.get("finished_at") or "") > str(
217
+ stamped.get("finished_at") or ""
218
+ ) else stamped
219
+
220
+
221
+ def enqueue_failed_execution(
222
+ review_queue: Any,
223
+ *,
224
+ workflow: Dict[str, Any],
225
+ run_id: Optional[str],
226
+ error: str,
227
+ user_email: Optional[str] = None,
228
+ workspace_id: Optional[str] = None,
229
+ ) -> Optional[Dict[str, Any]]:
230
+ """Failed live execution → review queue item (never raises)."""
231
+ if review_queue is None:
232
+ return None
233
+ name = str(workflow.get("name") or "Automation")
234
+ try:
235
+ return review_queue.create(
236
+ title=f"Automation failed: {name}"[:160],
237
+ summary=str(error or "")[:500],
238
+ source="workflow_run",
239
+ kind="automation_failure",
240
+ payload={
241
+ "workflow_id": workflow.get("id"),
242
+ "run_id": run_id,
243
+ "error": str(error or "")[:500],
244
+ },
245
+ provenance={
246
+ "workflow_id": workflow.get("id"),
247
+ "run_id": run_id,
248
+ "origin": "automation_run_now",
249
+ },
250
+ user_email=user_email,
251
+ workspace_id=workspace_id,
252
+ )
253
+ except Exception: # noqa: BLE001 — review enqueue is best-effort surfacing
254
+ LOGGER.exception("automation failure review enqueue failed")
255
+ return None
256
+
257
+
258
+ __all__ = [
259
+ "AUTOMATION_CREATED_FROM",
260
+ "is_automation_workflow",
261
+ "dry_run_report",
262
+ "build_last_execution",
263
+ "summarize_workflow_run",
264
+ "last_execution_view",
265
+ "enqueue_failed_execution",
266
+ ]