ltcai 4.3.1 → 4.4.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.
- package/README.md +191 -278
- package/docs/CHANGELOG.md +128 -0
- package/docs/V4_3_2_DEADCODE_AUDIT_REPORT.md +174 -0
- package/docs/V4_3_2_DOCUMENTATION_CLEANUP_REPORT.md +81 -0
- package/docs/V4_3_2_GITHUB_VERCEL_CHECK_REPORT.md +75 -0
- package/docs/V4_3_2_GRAPH_UX_REPORT.md +48 -0
- package/docs/V4_3_2_INDEPENDENT_AUDIT_PACKAGE.md +209 -0
- package/docs/V4_3_2_PRODUCT_POLISH_REPORT.md +57 -0
- package/docs/V4_3_2_SELF_AUDIT_REPORT.md +63 -0
- package/docs/V4_3_2_VALIDATION_REPORT.md +97 -0
- package/docs/V4_3_3_VALIDATION_REPORT.md +46 -0
- package/docs/V4_4_0_EXTRACTION_REPORT.md +239 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +18 -19
- package/frontend/openapi.json +1 -1
- package/frontend/src/components/primitives.tsx +92 -10
- package/frontend/src/pages/Act.tsx +11 -9
- package/frontend/src/pages/Ask.tsx +2 -2
- package/frontend/src/pages/Brain.tsx +607 -65
- package/frontend/src/pages/Capture.tsx +11 -7
- package/frontend/src/pages/Library.tsx +3 -3
- package/frontend/src/pages/System.tsx +186 -23
- package/lattice_brain/__init__.py +38 -23
- package/lattice_brain/_kg_common.py +11 -1
- package/lattice_brain/context.py +212 -2
- package/lattice_brain/conversations.py +234 -1
- package/lattice_brain/discovery.py +11 -1
- package/lattice_brain/documents.py +11 -1
- package/lattice_brain/graph/__init__.py +28 -0
- package/lattice_brain/graph/_kg_common.py +1123 -0
- package/lattice_brain/graph/curator.py +473 -0
- package/lattice_brain/graph/discovery.py +1455 -0
- package/lattice_brain/graph/documents.py +218 -0
- package/lattice_brain/graph/identity.py +175 -0
- package/lattice_brain/graph/ingest.py +644 -0
- package/lattice_brain/graph/network.py +205 -0
- package/lattice_brain/graph/projection.py +571 -0
- package/lattice_brain/graph/provenance.py +401 -0
- package/lattice_brain/graph/retrieval.py +1341 -0
- package/lattice_brain/graph/schema.py +640 -0
- package/lattice_brain/graph/store.py +237 -0
- package/lattice_brain/graph/write_master.py +225 -0
- package/lattice_brain/identity.py +11 -13
- package/lattice_brain/ingest.py +11 -1
- package/lattice_brain/ingestion.py +318 -0
- package/lattice_brain/memory.py +100 -1
- package/lattice_brain/network.py +11 -1
- package/lattice_brain/portability.py +431 -0
- package/lattice_brain/projection.py +11 -1
- package/lattice_brain/provenance.py +11 -1
- package/lattice_brain/retrieval.py +11 -1
- package/lattice_brain/runtime/__init__.py +32 -0
- package/lattice_brain/runtime/agent_runtime.py +569 -0
- package/lattice_brain/runtime/hooks.py +754 -0
- package/lattice_brain/runtime/multi_agent.py +795 -0
- package/lattice_brain/schema.py +11 -1
- package/lattice_brain/store.py +10 -2
- package/lattice_brain/workflow.py +461 -0
- package/lattice_brain/write_master.py +11 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +2 -2
- package/latticeai/api/browser.py +1 -1
- package/latticeai/api/chat.py +1 -1
- package/latticeai/api/computer_use.py +1 -1
- package/latticeai/api/hooks.py +2 -2
- package/latticeai/api/mcp.py +1 -1
- package/latticeai/api/tools.py +1 -1
- package/latticeai/api/workflow_designer.py +2 -2
- package/latticeai/app_factory.py +4 -4
- package/latticeai/brain/__init__.py +24 -6
- package/latticeai/brain/_kg_common.py +11 -1117
- package/latticeai/brain/context.py +12 -208
- package/latticeai/brain/conversations.py +12 -231
- package/latticeai/brain/discovery.py +13 -1451
- package/latticeai/brain/documents.py +13 -214
- package/latticeai/brain/identity.py +11 -169
- package/latticeai/brain/ingest.py +13 -640
- package/latticeai/brain/memory.py +12 -97
- package/latticeai/brain/network.py +12 -200
- package/latticeai/brain/projection.py +13 -567
- package/latticeai/brain/provenance.py +13 -397
- package/latticeai/brain/retrieval.py +13 -1337
- package/latticeai/brain/schema.py +12 -635
- package/latticeai/brain/store.py +13 -233
- package/latticeai/brain/write_master.py +13 -221
- package/latticeai/core/agent.py +1 -1
- package/latticeai/core/agent_registry.py +2 -2
- package/latticeai/core/builtin_hooks.py +2 -2
- package/latticeai/core/graph_curator.py +6 -468
- package/latticeai/core/hooks.py +6 -749
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +6 -790
- package/latticeai/core/workflow_engine.py +6 -456
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/agent_runtime.py +6 -564
- package/latticeai/services/ingestion.py +6 -313
- package/latticeai/services/kg_portability.py +6 -426
- package/latticeai/services/platform_runtime.py +3 -3
- package/latticeai/services/run_executor.py +1 -1
- package/latticeai/services/upload_service.py +1 -1
- package/p_reinforce.py +1 -1
- package/package.json +3 -6
- package/scripts/build_vercel_static.mjs +77 -0
- package/scripts/bump_version.py +1 -1
- package/scripts/check_markdown_links.mjs +75 -0
- package/scripts/wheel_smoke.py +7 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/main.rs +12 -2
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +5 -5
- package/static/app/assets/index-CHHal8Zl.css +2 -0
- package/static/app/assets/index-pdzil9ac.js +333 -0
- package/static/app/assets/index-pdzil9ac.js.map +1 -0
- package/static/app/index.html +2 -2
- package/latticeai/api/deps.py +0 -15
- package/scripts/capture/README.md +0 -28
- package/scripts/capture/capture_enterprise.js +0 -8
- package/scripts/capture/capture_graph.js +0 -8
- package/scripts/capture/capture_onboarding.js +0 -8
- package/scripts/capture/capture_page.js +0 -43
- package/scripts/capture/capture_release_media.js +0 -125
- package/scripts/capture/capture_skills.js +0 -8
- package/scripts/capture/capture_v340.js +0 -88
- package/scripts/capture/capture_workspace.js +0 -8
- package/scripts/generate_diagrams.py +0 -512
- package/scripts/release-0.3.1.sh +0 -105
- package/scripts/take_screenshots.js +0 -69
- package/static/app/assets/index-BhPuj8rT.js +0 -333
- package/static/app/assets/index-BhPuj8rT.js.map +0 -1
- package/static/app/assets/index-yZswHE3d.css +0 -2
- package/static/css/tokens.3ba22e37.css +0 -260
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
"""Hooks platform — a persisted registry of lifecycle extension points.
|
|
2
|
+
|
|
3
|
+
Lattice AI runs several behaviours at well-defined points in the agent / tool /
|
|
4
|
+
workflow lifecycle (audit logging, secret redaction, sensitive-data
|
|
5
|
+
classification, tool-permission gating, memory snapshots, workflow replay
|
|
6
|
+
logging). v3.2.0 makes those points *first-class and inspectable*: every hook is
|
|
7
|
+
listed, ordered, and individually enable/disable-able, and users can register
|
|
8
|
+
their own custom hooks alongside the built-ins.
|
|
9
|
+
|
|
10
|
+
The registry owns metadata, ordering and the enabled flag (persisted to
|
|
11
|
+
``data_dir/hooks.json``). Built-in hooks carry ``source="builtin"`` and map onto
|
|
12
|
+
behaviour the platform already performs; ``managed`` records whether the
|
|
13
|
+
behaviour is enforced by the platform (``platform``) or is an advisory hook the
|
|
14
|
+
user has registered (``user``). The registry never silently drops a hook: the
|
|
15
|
+
full set is always returned so the UI can show exactly what runs and in which
|
|
16
|
+
order.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import shlex
|
|
24
|
+
import subprocess
|
|
25
|
+
import tempfile
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from collections import deque
|
|
29
|
+
from datetime import datetime
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
HOOK_KINDS = (
|
|
35
|
+
"pre_run",
|
|
36
|
+
"post_run",
|
|
37
|
+
"pre_tool",
|
|
38
|
+
"post_tool",
|
|
39
|
+
"pre_workflow",
|
|
40
|
+
"post_workflow",
|
|
41
|
+
"pre_upload",
|
|
42
|
+
"post_upload",
|
|
43
|
+
"pre_index",
|
|
44
|
+
"post_index",
|
|
45
|
+
"agent",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Kinds retired in v3.4.1 in favour of the explicit pre_/post_ lifecycle pairs.
|
|
49
|
+
# Accepted on input and mapped forward so older callers / persisted custom hooks
|
|
50
|
+
# never break.
|
|
51
|
+
LEGACY_KIND_ALIASES = {
|
|
52
|
+
"workflow": "post_workflow",
|
|
53
|
+
"pipeline": "post_index",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Hook statuses a dispatch can record.
|
|
57
|
+
HOOK_STATUSES = ("ok", "blocked", "error", "skipped", "advisory")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _now() -> str:
|
|
61
|
+
return datetime.now().isoformat(timespec="seconds")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class HookContext:
|
|
65
|
+
"""Mutable execution context handed to every hook in a dispatch.
|
|
66
|
+
|
|
67
|
+
A bound hook runner may inspect or modify :attr:`payload` (e.g. redact
|
|
68
|
+
secrets in place), attach data via :meth:`set`, or halt a pending action
|
|
69
|
+
with :meth:`block`. Blocking is honoured for the ``pre_*`` kinds that gate
|
|
70
|
+
real work (a blocked ``pre_run`` stops the agent run; a blocked ``pre_tool``
|
|
71
|
+
stops the tool call).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
__slots__ = (
|
|
75
|
+
"kind", "event", "payload", "metadata",
|
|
76
|
+
"user_email", "workspace_id", "blocked", "block_reason", "notes",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
kind: str,
|
|
82
|
+
event: str = "",
|
|
83
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
84
|
+
*,
|
|
85
|
+
user_email: Optional[str] = None,
|
|
86
|
+
workspace_id: Optional[str] = None,
|
|
87
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
88
|
+
):
|
|
89
|
+
self.kind = kind
|
|
90
|
+
self.event = event or kind
|
|
91
|
+
self.payload = dict(payload or {})
|
|
92
|
+
self.metadata = dict(metadata or {})
|
|
93
|
+
self.user_email = user_email
|
|
94
|
+
self.workspace_id = workspace_id
|
|
95
|
+
self.blocked = False
|
|
96
|
+
self.block_reason = ""
|
|
97
|
+
self.notes: List[str] = []
|
|
98
|
+
|
|
99
|
+
def set(self, key: str, value: Any) -> "HookContext":
|
|
100
|
+
self.payload[key] = value
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def block(self, reason: str = "") -> "HookContext":
|
|
104
|
+
self.blocked = True
|
|
105
|
+
self.block_reason = reason or "blocked by hook"
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def note(self, message: str) -> "HookContext":
|
|
109
|
+
self.notes.append(str(message))
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
113
|
+
return {
|
|
114
|
+
"kind": self.kind,
|
|
115
|
+
"event": self.event,
|
|
116
|
+
"payload": self.payload,
|
|
117
|
+
"metadata": self.metadata,
|
|
118
|
+
"user_email": self.user_email,
|
|
119
|
+
"workspace_id": self.workspace_id,
|
|
120
|
+
"blocked": self.blocked,
|
|
121
|
+
"block_reason": self.block_reason,
|
|
122
|
+
"notes": list(self.notes),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class HookResult:
|
|
127
|
+
"""The outcome of running a single hook (one entry in a dispatch)."""
|
|
128
|
+
|
|
129
|
+
__slots__ = (
|
|
130
|
+
"hook_id", "name", "kind", "status", "detail", "output",
|
|
131
|
+
"duration_ms", "blocked", "source", "binding", "started_at",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
hook_id: str,
|
|
138
|
+
name: str = "",
|
|
139
|
+
kind: str = "",
|
|
140
|
+
status: str = "ok",
|
|
141
|
+
detail: str = "",
|
|
142
|
+
output: str = "",
|
|
143
|
+
duration_ms: int = 0,
|
|
144
|
+
blocked: bool = False,
|
|
145
|
+
source: str = "",
|
|
146
|
+
binding: str = "",
|
|
147
|
+
started_at: Optional[str] = None,
|
|
148
|
+
):
|
|
149
|
+
self.hook_id = hook_id
|
|
150
|
+
self.name = name
|
|
151
|
+
self.kind = kind
|
|
152
|
+
self.status = status # ok | blocked | error | skipped | advisory
|
|
153
|
+
self.detail = detail
|
|
154
|
+
self.output = output
|
|
155
|
+
self.duration_ms = duration_ms
|
|
156
|
+
self.blocked = blocked
|
|
157
|
+
self.source = source
|
|
158
|
+
self.binding = binding
|
|
159
|
+
self.started_at = started_at or _now()
|
|
160
|
+
|
|
161
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
162
|
+
return {
|
|
163
|
+
"hook_id": self.hook_id,
|
|
164
|
+
"name": self.name,
|
|
165
|
+
"kind": self.kind,
|
|
166
|
+
"status": self.status,
|
|
167
|
+
"detail": self.detail,
|
|
168
|
+
"output": self.output,
|
|
169
|
+
"duration_ms": self.duration_ms,
|
|
170
|
+
"blocked": self.blocked,
|
|
171
|
+
"source": self.source,
|
|
172
|
+
"binding": self.binding,
|
|
173
|
+
"started_at": self.started_at,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def hook_context(kind: str, event: str = "", **kwargs: Any) -> HookContext:
|
|
178
|
+
"""Factory for a :class:`HookContext` (matches the public hook vocabulary)."""
|
|
179
|
+
return HookContext(kind, event, **kwargs)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def hook_result(**kwargs: Any) -> HookResult:
|
|
183
|
+
"""Factory for a :class:`HookResult`."""
|
|
184
|
+
return HookResult(**kwargs)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def dispatch_tool(
|
|
188
|
+
hooks: Any,
|
|
189
|
+
tool_name: str,
|
|
190
|
+
args: Any,
|
|
191
|
+
run_fn: Callable[[], Any],
|
|
192
|
+
*,
|
|
193
|
+
user_email: Optional[str] = None,
|
|
194
|
+
workspace_id: Optional[str] = None,
|
|
195
|
+
source: str = "",
|
|
196
|
+
) -> Any:
|
|
197
|
+
"""Run a tool through the shared ``pre_tool`` → execute → ``post_tool`` lifecycle.
|
|
198
|
+
|
|
199
|
+
This is the single tool-dispatch path so every caller (the HTTP ``/tools/*``
|
|
200
|
+
routes, the single-agent runtime in :mod:`latticeai.core.agent`, and the
|
|
201
|
+
workflow tool node) fires the same hooks. A blocking ``pre_tool`` hook raises
|
|
202
|
+
:class:`PermissionError`; a tool error still fires ``post_tool`` (status
|
|
203
|
+
``error``) before re-raising. With ``hooks=None`` it is a transparent
|
|
204
|
+
pass-through, so the tool path is unchanged when hooks are absent.
|
|
205
|
+
"""
|
|
206
|
+
if hooks is None:
|
|
207
|
+
return run_fn()
|
|
208
|
+
try:
|
|
209
|
+
arg_keys = list(args.keys()) if isinstance(args, dict) else []
|
|
210
|
+
except Exception:
|
|
211
|
+
arg_keys = []
|
|
212
|
+
pre = hooks.fire_hook(
|
|
213
|
+
"pre_tool", f"tool.{tool_name}",
|
|
214
|
+
payload={"tool": tool_name, "args_keys": arg_keys, "source": source},
|
|
215
|
+
user_email=user_email, workspace_id=workspace_id,
|
|
216
|
+
)
|
|
217
|
+
if pre.get("blocked"):
|
|
218
|
+
raise PermissionError(pre.get("block_reason") or f"Tool '{tool_name}' blocked by a pre_tool hook.")
|
|
219
|
+
try:
|
|
220
|
+
result = run_fn()
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
hooks.fire_hook(
|
|
223
|
+
"post_tool", f"tool.{tool_name}",
|
|
224
|
+
payload={"tool": tool_name, "status": "error", "detail": str(exc), "source": source},
|
|
225
|
+
user_email=user_email, workspace_id=workspace_id,
|
|
226
|
+
)
|
|
227
|
+
raise
|
|
228
|
+
hooks.fire_hook(
|
|
229
|
+
"post_tool", f"tool.{tool_name}",
|
|
230
|
+
payload={"tool": tool_name, "status": "ok", "source": source},
|
|
231
|
+
user_email=user_email, workspace_id=workspace_id,
|
|
232
|
+
)
|
|
233
|
+
return result
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# Built-in hooks describe lifecycle points the platform already exercises. They
|
|
237
|
+
# are honest reflections of existing behaviour (see the `binding` field), made
|
|
238
|
+
# visible and orderable here. Disabling a `managed="platform"` hook is recorded
|
|
239
|
+
# and surfaced, but core safety behaviours remain enforced by their owning
|
|
240
|
+
# subsystem — the UI states this explicitly so nothing is misrepresented.
|
|
241
|
+
BUILTIN_HOOKS: List[Dict[str, Any]] = [
|
|
242
|
+
{
|
|
243
|
+
"id": "builtin:redact-secrets",
|
|
244
|
+
"name": "Redact secrets",
|
|
245
|
+
"kind": "pre_run",
|
|
246
|
+
"order": 10,
|
|
247
|
+
"description": "Strip secret-like fields (token, password, api_key…) from agent context packets before a run.",
|
|
248
|
+
"binding": "lattice_brain.runtime.multi_agent._redact",
|
|
249
|
+
"managed": "platform",
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
"id": "builtin:research-memory-snapshot",
|
|
253
|
+
"name": "Research memory snapshot",
|
|
254
|
+
"kind": "agent",
|
|
255
|
+
"order": 20,
|
|
256
|
+
"description": "Capture a short-term memory snapshot after the researcher stage gathers context.",
|
|
257
|
+
"binding": "lattice_brain.runtime.multi_agent.default_role_runner",
|
|
258
|
+
"managed": "platform",
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
"id": "builtin:tool-permission-gate",
|
|
262
|
+
"name": "Tool permission gate",
|
|
263
|
+
"kind": "pre_tool",
|
|
264
|
+
"order": 10,
|
|
265
|
+
"description": "Require explicit approval for tools whose governance policy is not auto-approve.",
|
|
266
|
+
"binding": "latticeai.core.tool_registry.ToolRegistry.permission",
|
|
267
|
+
"managed": "platform",
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
"id": "builtin:sensitive-data-guard",
|
|
271
|
+
"name": "Sensitive-data guard",
|
|
272
|
+
"kind": "pre_tool",
|
|
273
|
+
"order": 20,
|
|
274
|
+
"description": "Classify outgoing content for sensitive data before tool execution.",
|
|
275
|
+
"binding": "server_app.classify_sensitive_message",
|
|
276
|
+
"managed": "platform",
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
"id": "builtin:audit-agent-run",
|
|
280
|
+
"name": "Audit agent run",
|
|
281
|
+
"kind": "post_run",
|
|
282
|
+
"order": 10,
|
|
283
|
+
"description": "Append every completed agent run to the workspace audit log.",
|
|
284
|
+
"binding": "lattice_brain.runtime.agent_runtime.AgentRuntime.start",
|
|
285
|
+
"managed": "platform",
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
"id": "builtin:workflow-replay-log",
|
|
289
|
+
"name": "Workflow replay log",
|
|
290
|
+
"kind": "workflow",
|
|
291
|
+
"order": 10,
|
|
292
|
+
"description": "Record each workflow run's timeline so it can be replayed step by step.",
|
|
293
|
+
"binding": "latticeai.api.workflow_designer",
|
|
294
|
+
"managed": "platform",
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
"id": "builtin:pipeline-index-status",
|
|
298
|
+
"name": "Pipeline index status",
|
|
299
|
+
"kind": "pipeline",
|
|
300
|
+
"order": 10,
|
|
301
|
+
"description": "Publish ingest / embed / graph-build pipeline state to the retrieval index status.",
|
|
302
|
+
"binding": "latticeai.api.search",
|
|
303
|
+
"managed": "platform",
|
|
304
|
+
},
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
# Built-in hooks now bucket onto the v3.4.1 lifecycle pairs.
|
|
308
|
+
for _hook in BUILTIN_HOOKS:
|
|
309
|
+
_hook["kind"] = LEGACY_KIND_ALIASES.get(_hook["kind"], _hook["kind"])
|
|
310
|
+
del _hook
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class HooksRegistry:
|
|
314
|
+
"""Persisted registry of lifecycle hooks (built-in + user-registered)."""
|
|
315
|
+
|
|
316
|
+
def __init__(self, path: Path, *, command_timeout: float = 20.0, run_log_limit: int = 100):
|
|
317
|
+
self.path = Path(path)
|
|
318
|
+
self._state: Dict[str, Any] = self._load()
|
|
319
|
+
# Runtime dispatch state: in-process runners bound to hook ids, plus a
|
|
320
|
+
# bounded, persisted log of recent executions so the UI can show that
|
|
321
|
+
# hooks actually ran (not just that they are registered).
|
|
322
|
+
self._runtime_runners: Dict[str, Callable[[HookContext], Any]] = {}
|
|
323
|
+
self._command_timeout = float(command_timeout)
|
|
324
|
+
self._run_lock = threading.Lock()
|
|
325
|
+
self._runs_path = self.path.parent / "hooks_runs.json"
|
|
326
|
+
self._runs: deque = deque(self._load_runs(), maxlen=int(run_log_limit))
|
|
327
|
+
|
|
328
|
+
# ── persistence ───────────────────────────────────────────────────────
|
|
329
|
+
def _load(self) -> Dict[str, Any]:
|
|
330
|
+
if self.path.exists():
|
|
331
|
+
try:
|
|
332
|
+
with open(self.path, "r", encoding="utf-8") as fh:
|
|
333
|
+
data = json.load(fh)
|
|
334
|
+
if isinstance(data, dict):
|
|
335
|
+
data.setdefault("custom", [])
|
|
336
|
+
data.setdefault("overrides", {})
|
|
337
|
+
return data
|
|
338
|
+
except Exception:
|
|
339
|
+
pass
|
|
340
|
+
return {"custom": [], "overrides": {}}
|
|
341
|
+
|
|
342
|
+
def _save(self) -> None:
|
|
343
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
fd, tmp = tempfile.mkstemp(dir=str(self.path.parent), suffix=".tmp")
|
|
345
|
+
try:
|
|
346
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
347
|
+
json.dump(self._state, fh, ensure_ascii=False, indent=2)
|
|
348
|
+
os.replace(tmp, self.path)
|
|
349
|
+
finally:
|
|
350
|
+
if os.path.exists(tmp):
|
|
351
|
+
os.unlink(tmp)
|
|
352
|
+
|
|
353
|
+
# ── views ─────────────────────────────────────────────────────────────
|
|
354
|
+
def _materialize(self) -> List[Dict[str, Any]]:
|
|
355
|
+
overrides: Dict[str, Any] = self._state.get("overrides", {})
|
|
356
|
+
hooks: List[Dict[str, Any]] = []
|
|
357
|
+
for base in BUILTIN_HOOKS:
|
|
358
|
+
ov = overrides.get(base["id"], {})
|
|
359
|
+
hook = dict(base)
|
|
360
|
+
hook["source"] = "builtin"
|
|
361
|
+
hook["enabled"] = bool(ov.get("enabled", True))
|
|
362
|
+
if "order" in ov:
|
|
363
|
+
hook["order"] = ov["order"]
|
|
364
|
+
hook["removable"] = False
|
|
365
|
+
hooks.append(hook)
|
|
366
|
+
for custom in self._state.get("custom", []):
|
|
367
|
+
hook = dict(custom)
|
|
368
|
+
hook["source"] = "user"
|
|
369
|
+
hook.setdefault("managed", "user")
|
|
370
|
+
hook.setdefault("binding", "advisory")
|
|
371
|
+
hook["enabled"] = bool(custom.get("enabled", True))
|
|
372
|
+
hook["removable"] = True
|
|
373
|
+
hooks.append(hook)
|
|
374
|
+
# Honest execution flag: a hook actually runs only if a runner is bound
|
|
375
|
+
# (built-ins) or it carries a command (user hooks); otherwise it is
|
|
376
|
+
# advisory (listed + ordered, but a no-op when fired).
|
|
377
|
+
for hook in hooks:
|
|
378
|
+
hook["executable"] = self.has_runner(hook["id"]) or bool(str(hook.get("command") or "").strip())
|
|
379
|
+
hook["advisory"] = not hook["executable"]
|
|
380
|
+
hooks.sort(key=lambda h: (HOOK_KINDS.index(h["kind"]) if h["kind"] in HOOK_KINDS else 99, h.get("order", 100), h["id"]))
|
|
381
|
+
return hooks
|
|
382
|
+
|
|
383
|
+
def list(self, kind: Optional[str] = None) -> Dict[str, Any]:
|
|
384
|
+
hooks = self._materialize()
|
|
385
|
+
if kind:
|
|
386
|
+
hooks = [h for h in hooks if h["kind"] == kind]
|
|
387
|
+
counts: Dict[str, Dict[str, int]] = {}
|
|
388
|
+
for h in self._materialize():
|
|
389
|
+
bucket = counts.setdefault(h["kind"], {"total": 0, "enabled": 0})
|
|
390
|
+
bucket["total"] += 1
|
|
391
|
+
if h["enabled"]:
|
|
392
|
+
bucket["enabled"] += 1
|
|
393
|
+
return {
|
|
394
|
+
"hooks": hooks,
|
|
395
|
+
"kinds": list(HOOK_KINDS),
|
|
396
|
+
"counts": counts,
|
|
397
|
+
"total": len(hooks),
|
|
398
|
+
"enabled": sum(1 for h in hooks if h["enabled"]),
|
|
399
|
+
"generated_at": _now(),
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
def get(self, hook_id: str) -> Optional[Dict[str, Any]]:
|
|
403
|
+
return next((h for h in self._materialize() if h["id"] == hook_id), None)
|
|
404
|
+
|
|
405
|
+
def inspect(self, hook_id: str) -> Dict[str, Any]:
|
|
406
|
+
hook = self.get(hook_id)
|
|
407
|
+
if hook is None:
|
|
408
|
+
raise KeyError(hook_id)
|
|
409
|
+
detail = dict(hook)
|
|
410
|
+
detail["advisory"] = hook.get("managed") != "platform"
|
|
411
|
+
detail["note"] = (
|
|
412
|
+
"Enforced by its owning subsystem; the registry controls visibility and ordering."
|
|
413
|
+
if hook.get("managed") == "platform"
|
|
414
|
+
else "User-registered hook: listed, ordered and inspectable; runs advisory in this build."
|
|
415
|
+
)
|
|
416
|
+
return detail
|
|
417
|
+
|
|
418
|
+
# ── mutations ─────────────────────────────────────────────────────────
|
|
419
|
+
def set_enabled(self, hook_id: str, enabled: bool) -> Dict[str, Any]:
|
|
420
|
+
if self.get(hook_id) is None:
|
|
421
|
+
raise KeyError(hook_id)
|
|
422
|
+
if hook_id.startswith("builtin:"):
|
|
423
|
+
self._state.setdefault("overrides", {}).setdefault(hook_id, {})["enabled"] = bool(enabled)
|
|
424
|
+
else:
|
|
425
|
+
for custom in self._state.get("custom", []):
|
|
426
|
+
if custom["id"] == hook_id:
|
|
427
|
+
custom["enabled"] = bool(enabled)
|
|
428
|
+
self._save()
|
|
429
|
+
return self.get(hook_id) # type: ignore[return-value]
|
|
430
|
+
|
|
431
|
+
def set_order(self, hook_id: str, order: int) -> Dict[str, Any]:
|
|
432
|
+
if self.get(hook_id) is None:
|
|
433
|
+
raise KeyError(hook_id)
|
|
434
|
+
order = int(order)
|
|
435
|
+
if hook_id.startswith("builtin:"):
|
|
436
|
+
self._state.setdefault("overrides", {}).setdefault(hook_id, {})["order"] = order
|
|
437
|
+
else:
|
|
438
|
+
for custom in self._state.get("custom", []):
|
|
439
|
+
if custom["id"] == hook_id:
|
|
440
|
+
custom["order"] = order
|
|
441
|
+
self._save()
|
|
442
|
+
return self.get(hook_id) # type: ignore[return-value]
|
|
443
|
+
|
|
444
|
+
def reorder(self, kind: str, ordered_ids: List[str]) -> Dict[str, Any]:
|
|
445
|
+
for idx, hook_id in enumerate(ordered_ids):
|
|
446
|
+
try:
|
|
447
|
+
self.set_order(hook_id, (idx + 1) * 10)
|
|
448
|
+
except KeyError:
|
|
449
|
+
continue
|
|
450
|
+
return self.list(kind=kind)
|
|
451
|
+
|
|
452
|
+
def register(
|
|
453
|
+
self,
|
|
454
|
+
*,
|
|
455
|
+
name: str,
|
|
456
|
+
kind: str,
|
|
457
|
+
description: str = "",
|
|
458
|
+
command: str = "",
|
|
459
|
+
order: Optional[int] = None,
|
|
460
|
+
enabled: bool = True,
|
|
461
|
+
) -> Dict[str, Any]:
|
|
462
|
+
if not str(name).strip():
|
|
463
|
+
raise ValueError("name is required")
|
|
464
|
+
kind = LEGACY_KIND_ALIASES.get(kind, kind)
|
|
465
|
+
if kind not in HOOK_KINDS:
|
|
466
|
+
raise ValueError(f"kind must be one of {', '.join(HOOK_KINDS)}")
|
|
467
|
+
slug = str(name).strip().lower().replace(" ", "-")
|
|
468
|
+
hook_id = f"user:{slug}"
|
|
469
|
+
existing = {c["id"] for c in self._state.get("custom", [])}
|
|
470
|
+
if hook_id in existing:
|
|
471
|
+
hook_id = f"user:{slug}-{len(existing) + 1}"
|
|
472
|
+
entry = {
|
|
473
|
+
"id": hook_id,
|
|
474
|
+
"name": str(name).strip(),
|
|
475
|
+
"kind": kind,
|
|
476
|
+
"description": str(description or "").strip(),
|
|
477
|
+
"command": str(command or "").strip(),
|
|
478
|
+
"order": int(order) if order is not None else 100,
|
|
479
|
+
"enabled": bool(enabled),
|
|
480
|
+
"managed": "user",
|
|
481
|
+
"binding": "advisory",
|
|
482
|
+
"created_at": _now(),
|
|
483
|
+
}
|
|
484
|
+
self._state.setdefault("custom", []).append(entry)
|
|
485
|
+
self._save()
|
|
486
|
+
return entry
|
|
487
|
+
|
|
488
|
+
def remove(self, hook_id: str) -> Dict[str, Any]:
|
|
489
|
+
if hook_id.startswith("builtin:"):
|
|
490
|
+
raise ValueError("Built-in hooks cannot be removed; disable them instead.")
|
|
491
|
+
before = len(self._state.get("custom", []))
|
|
492
|
+
self._state["custom"] = [c for c in self._state.get("custom", []) if c["id"] != hook_id]
|
|
493
|
+
if len(self._state["custom"]) == before:
|
|
494
|
+
raise KeyError(hook_id)
|
|
495
|
+
self._save()
|
|
496
|
+
return {"removed": hook_id}
|
|
497
|
+
|
|
498
|
+
# ── execution engine ──────────────────────────────────────────────────
|
|
499
|
+
# The registry above owns *what* runs and in *what order*. The methods below
|
|
500
|
+
# own *running* it: a hook executes either via an in-process runner bound by
|
|
501
|
+
# its owning subsystem (built-ins) or, for user hooks, by running their
|
|
502
|
+
# ``command`` as a subprocess. ``pre_*`` hooks can block the pending action.
|
|
503
|
+
|
|
504
|
+
def register_hook(self, hook_id: str, runner: Callable[[HookContext], Any]) -> "HooksRegistry":
|
|
505
|
+
"""Bind a callable that *executes* when ``hook_id`` fires.
|
|
506
|
+
|
|
507
|
+
Built-in hooks bind their owning subsystem's real behaviour here at app
|
|
508
|
+
startup so dispatch performs actual work rather than a placeholder.
|
|
509
|
+
Returns ``self`` so registrations can be chained.
|
|
510
|
+
"""
|
|
511
|
+
if not callable(runner):
|
|
512
|
+
raise TypeError("runner must be callable")
|
|
513
|
+
self._runtime_runners[hook_id] = runner
|
|
514
|
+
return self
|
|
515
|
+
|
|
516
|
+
# Alias — descriptive name used by the wiring layer.
|
|
517
|
+
register_runtime_hook = register_hook
|
|
518
|
+
|
|
519
|
+
def unregister_hook(self, hook_id: str) -> None:
|
|
520
|
+
self._runtime_runners.pop(hook_id, None)
|
|
521
|
+
|
|
522
|
+
def has_runner(self, hook_id: str) -> bool:
|
|
523
|
+
return hook_id in self._runtime_runners
|
|
524
|
+
|
|
525
|
+
def run_hooks(
|
|
526
|
+
self,
|
|
527
|
+
kind: str,
|
|
528
|
+
context: Optional[HookContext] = None,
|
|
529
|
+
*,
|
|
530
|
+
event: Optional[str] = None,
|
|
531
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
532
|
+
user_email: Optional[str] = None,
|
|
533
|
+
workspace_id: Optional[str] = None,
|
|
534
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
535
|
+
) -> Dict[str, Any]:
|
|
536
|
+
"""Run every enabled hook of ``kind``, in order, against one context.
|
|
537
|
+
|
|
538
|
+
Returns the dispatch record: which hooks ran, whether the pending action
|
|
539
|
+
was blocked, and a per-hook result list. A ``pre_*`` hook that blocks
|
|
540
|
+
short-circuits the remaining hooks (fail-closed gate semantics).
|
|
541
|
+
"""
|
|
542
|
+
kind = LEGACY_KIND_ALIASES.get(kind, kind)
|
|
543
|
+
if kind not in HOOK_KINDS:
|
|
544
|
+
raise ValueError(f"kind must be one of {', '.join(HOOK_KINDS)}")
|
|
545
|
+
if context is None:
|
|
546
|
+
context = HookContext(
|
|
547
|
+
kind, event=event or kind, payload=payload,
|
|
548
|
+
user_email=user_email, workspace_id=workspace_id, metadata=metadata,
|
|
549
|
+
)
|
|
550
|
+
hooks = [h for h in self._materialize() if h["kind"] == kind and h.get("enabled")]
|
|
551
|
+
results: List[HookResult] = []
|
|
552
|
+
for hook in hooks:
|
|
553
|
+
res = self._run_one(hook, context)
|
|
554
|
+
results.append(res)
|
|
555
|
+
self._record_run(res, context)
|
|
556
|
+
if res.blocked:
|
|
557
|
+
context.block(res.detail or f"{hook['id']} blocked {context.event}")
|
|
558
|
+
break
|
|
559
|
+
return {
|
|
560
|
+
"kind": kind,
|
|
561
|
+
"event": context.event,
|
|
562
|
+
"ran": len(results),
|
|
563
|
+
"blocked": context.blocked,
|
|
564
|
+
"block_reason": context.block_reason,
|
|
565
|
+
"results": [r.as_dict() for r in results],
|
|
566
|
+
"generated_at": _now(),
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
def run_hook(
|
|
570
|
+
self,
|
|
571
|
+
hook_id: str,
|
|
572
|
+
context: Optional[HookContext] = None,
|
|
573
|
+
*,
|
|
574
|
+
event: Optional[str] = None,
|
|
575
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
576
|
+
user_email: Optional[str] = None,
|
|
577
|
+
workspace_id: Optional[str] = None,
|
|
578
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
579
|
+
) -> Dict[str, Any]:
|
|
580
|
+
"""Run a single hook by id (regardless of kind ordering)."""
|
|
581
|
+
hook = self.get(hook_id)
|
|
582
|
+
if hook is None:
|
|
583
|
+
raise KeyError(hook_id)
|
|
584
|
+
if context is None:
|
|
585
|
+
context = HookContext(
|
|
586
|
+
hook["kind"], event=event or hook_id, payload=payload,
|
|
587
|
+
user_email=user_email, workspace_id=workspace_id, metadata=metadata,
|
|
588
|
+
)
|
|
589
|
+
if not hook.get("enabled"):
|
|
590
|
+
res = HookResult(
|
|
591
|
+
hook_id=hook_id, name=hook.get("name", hook_id), kind=hook["kind"],
|
|
592
|
+
status="skipped", detail="hook disabled",
|
|
593
|
+
source=hook.get("source", ""), binding=hook.get("binding", ""),
|
|
594
|
+
)
|
|
595
|
+
else:
|
|
596
|
+
res = self._run_one(hook, context)
|
|
597
|
+
self._record_run(res, context)
|
|
598
|
+
return res.as_dict()
|
|
599
|
+
|
|
600
|
+
def fire_hook(
|
|
601
|
+
self,
|
|
602
|
+
kind: str,
|
|
603
|
+
event: str = "",
|
|
604
|
+
*,
|
|
605
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
606
|
+
user_email: Optional[str] = None,
|
|
607
|
+
workspace_id: Optional[str] = None,
|
|
608
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
609
|
+
context: Optional[HookContext] = None,
|
|
610
|
+
) -> Dict[str, Any]:
|
|
611
|
+
"""Fire-and-forget convenience over :meth:`run_hooks`.
|
|
612
|
+
|
|
613
|
+
Never raises — a dispatch failure is captured in the returned record so
|
|
614
|
+
a hook misconfiguration can never crash the lifecycle point that fired
|
|
615
|
+
it. Callers that need to *gate* on a block should read ``["blocked"]``.
|
|
616
|
+
"""
|
|
617
|
+
try:
|
|
618
|
+
return self.run_hooks(
|
|
619
|
+
kind, context, event=event or kind, payload=payload,
|
|
620
|
+
user_email=user_email, workspace_id=workspace_id, metadata=metadata,
|
|
621
|
+
)
|
|
622
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
623
|
+
return {"kind": kind, "event": event or kind, "ran": 0, "blocked": False,
|
|
624
|
+
"block_reason": "", "error": str(exc), "results": [], "generated_at": _now()}
|
|
625
|
+
|
|
626
|
+
# ── single-hook execution ─────────────────────────────────────────────
|
|
627
|
+
def _run_one(self, hook: Dict[str, Any], context: HookContext) -> HookResult:
|
|
628
|
+
hook_id = hook["id"]
|
|
629
|
+
start = time.perf_counter()
|
|
630
|
+
runner = self._runtime_runners.get(hook_id)
|
|
631
|
+
try:
|
|
632
|
+
if runner is not None:
|
|
633
|
+
out = runner(context)
|
|
634
|
+
status, detail, output, blocked = self._interpret_runner_output(out, context)
|
|
635
|
+
elif str(hook.get("command") or "").strip():
|
|
636
|
+
status, detail, output, blocked = self._run_command(hook, context)
|
|
637
|
+
else:
|
|
638
|
+
# No bound runner and no command → advisory (listed + ordered only).
|
|
639
|
+
status, detail, output, blocked = "advisory", "", "", False
|
|
640
|
+
except Exception as exc: # a misbehaving hook never breaks the dispatch
|
|
641
|
+
status, detail, output, blocked = "error", str(exc)[:500], "", False
|
|
642
|
+
duration_ms = int((time.perf_counter() - start) * 1000)
|
|
643
|
+
return HookResult(
|
|
644
|
+
hook_id=hook_id,
|
|
645
|
+
name=hook.get("name", hook_id),
|
|
646
|
+
kind=hook["kind"],
|
|
647
|
+
status=status,
|
|
648
|
+
detail=detail,
|
|
649
|
+
output=output,
|
|
650
|
+
duration_ms=duration_ms,
|
|
651
|
+
blocked=blocked,
|
|
652
|
+
source=hook.get("source", ""),
|
|
653
|
+
binding=hook.get("binding", ""),
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
@staticmethod
|
|
657
|
+
def _interpret_runner_output(out: Any, context: HookContext):
|
|
658
|
+
"""Normalize a runner's return value into (status, detail, output, blocked).
|
|
659
|
+
|
|
660
|
+
A runner may return ``None`` (ok), a ``str`` (ok + output text), or a
|
|
661
|
+
dict ``{status?, detail?, output?, block?}``. Calling ``context.block()``
|
|
662
|
+
also blocks, regardless of the return value.
|
|
663
|
+
"""
|
|
664
|
+
blocked = bool(getattr(context, "blocked", False))
|
|
665
|
+
if isinstance(out, dict):
|
|
666
|
+
status = str(out.get("status") or ("blocked" if (out.get("block") or blocked) else "ok"))
|
|
667
|
+
block = bool(out.get("block")) or blocked or status == "blocked"
|
|
668
|
+
detail = str(out.get("detail") or context.block_reason or "")[:500]
|
|
669
|
+
return status, detail, str(out.get("output") or "")[:4000], block
|
|
670
|
+
if isinstance(out, str):
|
|
671
|
+
return ("blocked" if blocked else "ok", context.block_reason if blocked else "", out[:4000], blocked)
|
|
672
|
+
# None or anything else → ok (or blocked if the runner called context.block()).
|
|
673
|
+
return ("blocked" if blocked else "ok", context.block_reason if blocked else "", "", blocked)
|
|
674
|
+
|
|
675
|
+
def _run_command(self, hook: Dict[str, Any], context: HookContext):
|
|
676
|
+
"""Run a user hook's ``command`` as a subprocess with the context on stdin.
|
|
677
|
+
|
|
678
|
+
The full context is provided both as the ``LATTICE_HOOK_CONTEXT`` env var
|
|
679
|
+
and on stdin (JSON). Exit code 0 = ok. A non-zero exit from a ``pre_*``
|
|
680
|
+
hook gates (blocks) the pending action; from any other kind it is
|
|
681
|
+
recorded as an error without blocking. A timeout fails closed for gates.
|
|
682
|
+
"""
|
|
683
|
+
cmd = str(hook.get("command") or "").strip()
|
|
684
|
+
try:
|
|
685
|
+
argv = shlex.split(cmd)
|
|
686
|
+
except ValueError as exc:
|
|
687
|
+
return "error", f"invalid command: {exc}", "", False
|
|
688
|
+
if not argv:
|
|
689
|
+
return "skipped", "empty command", "", False
|
|
690
|
+
ctx_json = json.dumps(context.as_dict(), ensure_ascii=False)
|
|
691
|
+
env = dict(os.environ)
|
|
692
|
+
env.update({
|
|
693
|
+
"LATTICE_HOOK_KIND": context.kind,
|
|
694
|
+
"LATTICE_HOOK_EVENT": context.event,
|
|
695
|
+
"LATTICE_HOOK_ID": str(hook.get("id", "")),
|
|
696
|
+
"LATTICE_HOOK_CONTEXT": ctx_json,
|
|
697
|
+
})
|
|
698
|
+
is_gate = context.kind.startswith("pre_")
|
|
699
|
+
try:
|
|
700
|
+
proc = subprocess.run(
|
|
701
|
+
argv, input=ctx_json, capture_output=True, text=True,
|
|
702
|
+
timeout=self._command_timeout, env=env,
|
|
703
|
+
)
|
|
704
|
+
except subprocess.TimeoutExpired:
|
|
705
|
+
return ("blocked" if is_gate else "error",
|
|
706
|
+
f"timed out after {self._command_timeout:.0f}s", "", is_gate)
|
|
707
|
+
except Exception as exc:
|
|
708
|
+
return "error", str(exc)[:500], "", False
|
|
709
|
+
output = (proc.stdout or "")[:4000]
|
|
710
|
+
if proc.returncode == 0:
|
|
711
|
+
return "ok", "", output, False
|
|
712
|
+
detail = (proc.stderr or proc.stdout or f"exit code {proc.returncode}").strip()[:500]
|
|
713
|
+
return ("blocked" if is_gate else "error", detail, output, is_gate)
|
|
714
|
+
|
|
715
|
+
# ── run log ────────────────────────────────────────────────────────────
|
|
716
|
+
def _load_runs(self) -> List[Dict[str, Any]]:
|
|
717
|
+
try:
|
|
718
|
+
if self._runs_path.exists():
|
|
719
|
+
with open(self._runs_path, "r", encoding="utf-8") as fh:
|
|
720
|
+
data = json.load(fh)
|
|
721
|
+
if isinstance(data, list):
|
|
722
|
+
return data
|
|
723
|
+
except Exception:
|
|
724
|
+
pass
|
|
725
|
+
return []
|
|
726
|
+
|
|
727
|
+
def _save_runs(self) -> None:
|
|
728
|
+
try:
|
|
729
|
+
self._runs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
730
|
+
fd, tmp = tempfile.mkstemp(dir=str(self._runs_path.parent), suffix=".tmp")
|
|
731
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
732
|
+
json.dump(list(self._runs), fh, ensure_ascii=False, indent=2)
|
|
733
|
+
os.replace(tmp, self._runs_path)
|
|
734
|
+
except Exception: # pragma: no cover - the run log is best-effort
|
|
735
|
+
pass
|
|
736
|
+
|
|
737
|
+
def _record_run(self, result: HookResult, context: HookContext) -> None:
|
|
738
|
+
entry = result.as_dict()
|
|
739
|
+
entry["target_event"] = context.event
|
|
740
|
+
entry["target_kind"] = context.kind
|
|
741
|
+
with self._run_lock:
|
|
742
|
+
self._runs.appendleft(entry)
|
|
743
|
+
self._save_runs()
|
|
744
|
+
|
|
745
|
+
def recent_runs(self, limit: int = 50, kind: Optional[str] = None) -> Dict[str, Any]:
|
|
746
|
+
with self._run_lock:
|
|
747
|
+
runs = list(self._runs)
|
|
748
|
+
if kind:
|
|
749
|
+
runs = [r for r in runs if r.get("target_kind") == kind or r.get("kind") == kind]
|
|
750
|
+
return {
|
|
751
|
+
"runs": runs[: max(0, int(limit))],
|
|
752
|
+
"total": len(runs),
|
|
753
|
+
"generated_at": _now(),
|
|
754
|
+
}
|