halyn 0.2.0__py3-none-any.whl

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.
halyn/drivers/ssh.py ADDED
@@ -0,0 +1,131 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ SSH Driver v2 — With Manifest + Events.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ import shlex
11
+ from typing import Any
12
+
13
+ from nrp import NRPDriver, ShieldRule, ShieldType
14
+ from nrp import NRPId
15
+ from nrp import NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
16
+
17
+
18
+ class SSHDriver(NRPDriver):
19
+ """Control any machine via SSH. Self-describing."""
20
+
21
+ def __init__(self, host: str, user: str = "", key_path: str = "", port: int = 22) -> None:
22
+ super().__init__()
23
+ self.host = host
24
+ self.user = user
25
+ self.key_path = key_path
26
+ self.port = port
27
+
28
+ def manifest(self) -> NRPManifest:
29
+ nrp_id = self._nrp_id or NRPId.create("local", "server", self.host.replace(".", "-"))
30
+ return NRPManifest(
31
+ nrp_id=nrp_id,
32
+ manufacturer="Generic",
33
+ model="Linux Server",
34
+ observe=[
35
+ ChannelSpec("hostname", "string", description="Machine hostname"),
36
+ ChannelSpec("cpu", "int", description="CPU core count"),
37
+ ChannelSpec("ram", "string", description="RAM total and free"),
38
+ ChannelSpec("disk", "string", description="Root disk usage"),
39
+ ChannelSpec("load", "string", description="System load average"),
40
+ ChannelSpec("uptime", "string", description="System uptime"),
41
+ ChannelSpec("status", "string", description="Quick alive check"),
42
+ ],
43
+ act=[
44
+ ActionSpec("shell", {"command": "string — shell command"}, "Execute shell command", dangerous=True),
45
+ ActionSpec("file_read", {"path": "string — file path"}, "Read file contents"),
46
+ ActionSpec("file_write", {"path": "string", "content": "string"}, "Write file", dangerous=True),
47
+ ActionSpec("service_restart", {"service": "string"}, "Restart systemd service", dangerous=True),
48
+ ActionSpec("file_list", {"path": "string — directory"}, "List directory"),
49
+ ActionSpec("process_list", {}, "Top processes by memory"),
50
+ ActionSpec("log_tail", {"source": "string", "lines": "int"}, "Tail logs"),
51
+ ActionSpec("git_status", {"path": "string"}, "Git repository state"),
52
+ ],
53
+ shield=[
54
+ ShieldSpec("no_rm_rf", "pattern", "rm -rf", description="Block recursive delete"),
55
+ ShieldSpec("no_shutdown", "pattern", "shutdown", description="Block shutdown"),
56
+ ShieldSpec("no_reboot", "pattern", "reboot", description="Block reboot"),
57
+ ShieldSpec("no_mkfs", "pattern", "mkfs", description="Block format"),
58
+ ShieldSpec("no_dd", "pattern", "dd if=", description="Block raw disk write"),
59
+ ShieldSpec("confirm_deploy", "confirm", "deploy", description="Confirm before deploy"),
60
+ ],
61
+ )
62
+
63
+ async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
64
+ channels = channels or ["hostname", "cpu", "ram", "disk", "load"]
65
+ commands = {
66
+ "hostname": "hostname",
67
+ "cpu": "nproc",
68
+ "ram": "free -h | grep Mem",
69
+ "disk": "df -h / | tail -1",
70
+ "load": "cat /proc/loadavg",
71
+ "uptime": "uptime -p 2>/dev/null || uptime",
72
+ "status": "echo ok",
73
+ }
74
+ state: dict[str, Any] = {}
75
+ for ch in channels:
76
+ if ch in commands:
77
+ try:
78
+ state[ch] = self._exec(commands[ch]).strip()
79
+ except Exception as e:
80
+ state[ch] = f"error: {e}"
81
+ return state
82
+
83
+ async def act(self, command: str, args: dict[str, Any]) -> Any:
84
+ if command == "shell":
85
+ return self._exec(args.get("command", "echo no command"), args.get("timeout", 30))
86
+ elif command == "file_read":
87
+ return self._exec(f"cat {shlex.quote(args['path'])}", 10)
88
+ elif command == "file_write":
89
+ content = args["content"].replace("'", "'\\''")
90
+ path = shlex.quote(args["path"])
91
+ self._exec(f"mkdir -p $(dirname {path}) && printf '%s' '{content}' > {path}", 10)
92
+ return {"written": len(args["content"])}
93
+ elif command == "service_restart":
94
+ return self._exec(f"systemctl restart {shlex.quote(args['service'])}", 15)
95
+ elif command == "file_list":
96
+ return self._exec(f"ls -la {shlex.quote(args.get('path', '.'))}", 10)
97
+ elif command == "process_list":
98
+ return self._exec("ps aux --sort=-%mem | head -15", 10)
99
+ elif command == "log_tail":
100
+ src = args.get("source", "syslog")
101
+ n = min(args.get("lines", 30), 200)
102
+ if src.startswith("/"):
103
+ return self._exec(f"tail -{n} {shlex.quote(src)}", 10)
104
+ return self._exec(f"journalctl -u {shlex.quote(src)} --no-pager -n {n}", 10)
105
+ elif command == "git_status":
106
+ path = shlex.quote(args.get("path", "."))
107
+ return self._exec(f"cd {path} && git branch --show-current && git status --short && git log --oneline -3", 10)
108
+ return self._exec(args.get("command", command), args.get("timeout", 30))
109
+
110
+ def shield_rules(self) -> list[ShieldRule]:
111
+ return [
112
+ ShieldRule("no_rm_rf", ShieldType.PATTERN, "rm -rf"),
113
+ ShieldRule("no_shutdown", ShieldType.PATTERN, "shutdown"),
114
+ ShieldRule("no_reboot", ShieldType.PATTERN, "reboot"),
115
+ ShieldRule("no_mkfs", ShieldType.PATTERN, "mkfs"),
116
+ ShieldRule("no_dd", ShieldType.PATTERN, "dd if="),
117
+ ]
118
+
119
+ def _exec(self, cmd: str, timeout: int = 30) -> str:
120
+ parts = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5"]
121
+ if self.key_path:
122
+ parts += ["-i", self.key_path]
123
+ if self.port != 22:
124
+ parts += ["-p", str(self.port)]
125
+ target = f"{self.user}@{self.host}" if self.user else self.host
126
+ parts += [target, cmd]
127
+ r = subprocess.run(parts, capture_output=True, text=True, timeout=min(timeout, 300))
128
+ if r.returncode != 0 and r.stderr.strip():
129
+ raise RuntimeError(r.stderr[:500])
130
+ return r.stdout
131
+
@@ -0,0 +1,103 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ Unitree Driver — G1, H1, Go2 robots.
5
+
6
+ Uses Unitree SDK2 Python bindings.
7
+ Falls back to HTTP API if SDK not available.
8
+ """
9
+ from __future__ import annotations
10
+ from typing import Any
11
+ from nrp import NRPDriver, ShieldRule, ShieldType
12
+
13
+ class UnitreeDriver(NRPDriver):
14
+ def __init__(self, robot_ip: str = "192.168.123.161", model: str = "g1") -> None:
15
+ self.robot_ip = robot_ip
16
+ self.model = model
17
+ self._sdk: Any = None
18
+
19
+ @property
20
+ def kind(self) -> str: return "unitree"
21
+
22
+ @property
23
+ def capabilities(self) -> list[str]:
24
+ return ["stand", "sit", "walk", "stop", "pick", "place",
25
+ "move_joint", "set_speed", "get_state", "emergency_stop"]
26
+
27
+ async def connect(self) -> bool:
28
+ try:
29
+ import unitree_sdk2py as sdk
30
+ self._sdk = sdk.Robot(self.robot_ip)
31
+ self._sdk.connect()
32
+ return True
33
+ except ImportError:
34
+ # Try HTTP fallback
35
+ try:
36
+ import aiohttp
37
+ async with aiohttp.ClientSession() as s:
38
+ async with s.get(f"http://{self.robot_ip}:8080/status",
39
+ timeout=aiohttp.ClientTimeout(total=3)) as r:
40
+ return r.status == 200
41
+ except Exception:
42
+ return False
43
+
44
+ async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
45
+ channels = channels or ["joints", "imu", "battery", "mode"]
46
+ state: dict[str, Any] = {}
47
+ if self._sdk:
48
+ s = self._sdk.get_state()
49
+ if "joints" in channels:
50
+ state["joints"] = s.get("joint_angles", {})
51
+ if "imu" in channels:
52
+ state["imu"] = s.get("imu", {})
53
+ if "battery" in channels:
54
+ state["battery"] = s.get("battery_percent", 0)
55
+ if "mode" in channels:
56
+ state["mode"] = s.get("mode", "unknown")
57
+ else:
58
+ import aiohttp
59
+ async with aiohttp.ClientSession() as session:
60
+ async with session.get(f"http://{self.robot_ip}:8080/state") as r:
61
+ state = await r.json()
62
+ return state
63
+
64
+ async def act(self, command: str, args: dict[str, Any]) -> Any:
65
+ if command == "emergency_stop":
66
+ if self._sdk:
67
+ self._sdk.emergency_stop()
68
+ return {"stopped": True}
69
+ if command == "stand":
70
+ if self._sdk: self._sdk.stand()
71
+ return {"mode": "standing"}
72
+ if command == "sit":
73
+ if self._sdk: self._sdk.sit()
74
+ return {"mode": "sitting"}
75
+ if command == "walk":
76
+ speed = args.get("speed", 0.3)
77
+ direction = args.get("direction", "forward")
78
+ if self._sdk: self._sdk.walk(speed, direction)
79
+ return {"walking": True, "speed": speed}
80
+ if command == "stop":
81
+ if self._sdk: self._sdk.stop()
82
+ return {"mode": "idle"}
83
+ if command == "pick":
84
+ target = args.get("target", "")
85
+ if self._sdk: self._sdk.pick(target)
86
+ return {"picking": target}
87
+ if command == "move_joint":
88
+ joint = args["joint"]
89
+ angle = args["angle"]
90
+ if self._sdk: self._sdk.move_joint(joint, angle)
91
+ return {"joint": joint, "angle": angle}
92
+ raise ValueError(f"Unknown unitree command: {command}")
93
+
94
+ def shield_rules(self) -> list[ShieldRule]:
95
+ return [
96
+ ShieldRule("max_speed", ShieldType.LIMIT, 1.5, "m/s", "Maximum walking speed"),
97
+ ShieldRule("max_joint_speed", ShieldType.LIMIT, 2.0, "rad/s", "Maximum joint velocity"),
98
+ ShieldRule("workspace", ShieldType.ZONE, {"x":[-3,3],"y":[-3,3],"z":[0,1.5]}, "m", "Allowed area"),
99
+ ShieldRule("min_battery", ShieldType.THRESHOLD, 10, "percent", "Stop below 10%"),
100
+ ShieldRule("max_payload", ShieldType.LIMIT, 3, "kg", "Maximum carry weight"),
101
+ ShieldRule("emergency_stop", ShieldType.COMMAND, True, description="Always allowed"),
102
+ ]
103
+
@@ -0,0 +1,175 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ WebSocket meta-driver — bidirectional real-time channels.
5
+
6
+ Covers: live dashboards, streaming APIs, real-time data feeds,
7
+ WebSocket-based IoT platforms, home automation bridges (e.g. Home Assistant).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ import time
16
+ from typing import Any
17
+
18
+ from nrp import (
19
+ NRPDriver, NRPManifest, NRPId,
20
+ ChannelSpec, ActionSpec, ShieldSpec, ShieldRule, ShieldType, Severity,
21
+ )
22
+
23
+ log = logging.getLogger("halyn.drivers.websocket")
24
+
25
+ try:
26
+ import aiohttp
27
+ HAS_WS = True
28
+ except ImportError:
29
+ HAS_WS = False
30
+
31
+
32
+ class WebSocketDriver(NRPDriver):
33
+ """
34
+ Persistent WebSocket connection with message routing.
35
+
36
+ Receives JSON messages, indexes them by a configurable key,
37
+ and exposes the latest state per channel. Outbound messages
38
+ are sent via act().
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ url: str,
44
+ auth_token: str = "",
45
+ channels: list[str] | None = None,
46
+ message_key: str = "type",
47
+ ping_interval: float = 30.0,
48
+ ) -> None:
49
+ super().__init__()
50
+ self.url = url
51
+ self.auth_token = auth_token
52
+ self.channel_names = channels or ["default"]
53
+ self.message_key = message_key
54
+ self.ping_interval = ping_interval
55
+ self._ws = None
56
+ self._session = None
57
+ self._state: dict[str, Any] = {}
58
+ self._msg_count = 0
59
+ self._connected_at = 0.0
60
+ self._listen_task = None
61
+
62
+ def manifest(self) -> NRPManifest:
63
+ observe = [
64
+ ChannelSpec("connected", "bool"),
65
+ ChannelSpec("message_count", "int"),
66
+ ChannelSpec("uptime", "float", unit="seconds"),
67
+ ]
68
+ for ch in self.channel_names:
69
+ observe.append(ChannelSpec(ch, "json", rate="on_change"))
70
+
71
+ return NRPManifest(
72
+ nrp_id=self._nrp_id or NRPId.create("local", "ws", self.url.split("/")[-1]),
73
+ manufacturer="WebSocket",
74
+ model=self.url,
75
+ observe=observe,
76
+ act=[
77
+ ActionSpec("send", {"message": "json — payload to send"}, "Send JSON message"),
78
+ ActionSpec("send_raw", {"data": "string"}, "Send raw text"),
79
+ ActionSpec("subscribe", {"channel": "string"}, "Subscribe to channel"),
80
+ ActionSpec("reconnect", {}, "Force reconnect"),
81
+ ],
82
+ shield=[ShieldSpec("rate", "limit", 100, "msg/s")],
83
+ )
84
+
85
+ async def connect(self) -> bool:
86
+ if not HAS_WS:
87
+ log.warning("websocket: aiohttp required")
88
+ return False
89
+ try:
90
+ headers: dict[str, str] = {}
91
+ if self.auth_token:
92
+ headers["Authorization"] = self.auth_token
93
+ self._session = aiohttp.ClientSession()
94
+ self._ws = await self._session.ws_connect(
95
+ self.url, headers=headers,
96
+ heartbeat=self.ping_interval,
97
+ )
98
+ self._connected_at = time.time()
99
+ self._listen_task = asyncio.create_task(self._listen())
100
+ log.info("ws.connected url=%s", self.url)
101
+ return True
102
+ except Exception as e:
103
+ log.error("ws.connect_failed url=%s error=%s", self.url, e)
104
+ return False
105
+
106
+ async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
107
+ state: dict[str, Any] = {
108
+ "connected": self._ws is not None and not self._ws.closed,
109
+ "message_count": self._msg_count,
110
+ "uptime": time.time() - self._connected_at if self._connected_at else 0,
111
+ }
112
+ targets = channels or self.channel_names
113
+ for ch in targets:
114
+ if ch in self._state:
115
+ state[ch] = self._state[ch]
116
+ return state
117
+
118
+ async def act(self, command: str, args: dict[str, Any]) -> Any:
119
+ if command == "reconnect":
120
+ await self.disconnect()
121
+ return {"reconnected": await self.connect()}
122
+
123
+ if not self._ws or self._ws.closed:
124
+ return {"error": "not connected"}
125
+
126
+ if command == "send":
127
+ msg = args.get("message", {})
128
+ await self._ws.send_json(msg)
129
+ return {"sent": True, "size": len(json.dumps(msg))}
130
+ if command == "send_raw":
131
+ data = args.get("data", "")
132
+ await self._ws.send_str(data)
133
+ return {"sent": True, "size": len(data)}
134
+ if command == "subscribe":
135
+ ch = args.get("channel", "")
136
+ if ch and ch not in self.channel_names:
137
+ self.channel_names.append(ch)
138
+ return {"subscribed": ch}
139
+
140
+ return {"error": f"unknown: {command}"}
141
+
142
+ def shield_rules(self) -> list[ShieldRule]:
143
+ return [ShieldRule("rate", ShieldType.LIMIT, 100)]
144
+
145
+ async def disconnect(self) -> None:
146
+ if self._listen_task:
147
+ self._listen_task.cancel()
148
+ if self._ws and not self._ws.closed:
149
+ await self._ws.close()
150
+ if self._session:
151
+ await self._session.close()
152
+ self._ws = None
153
+ self._session = None
154
+
155
+ async def _listen(self) -> None:
156
+ try:
157
+ async for msg in self._ws:
158
+ if msg.type == aiohttp.WSMsgType.TEXT:
159
+ self._msg_count += 1
160
+ try:
161
+ data = json.loads(msg.data)
162
+ key = data.get(self.message_key, "default")
163
+ self._state[key] = data
164
+ if self._event_bus:
165
+ await self.emit(f"ws_message_{key}", data=data)
166
+ except json.JSONDecodeError:
167
+ self._state["raw"] = msg.data
168
+ elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
169
+ break
170
+ except asyncio.CancelledError:
171
+ pass
172
+ except Exception as e:
173
+ log.error("ws.listen_error: %s", e)
174
+ if self._event_bus:
175
+ await self.emit("ws_disconnected", Severity.WARNING, error=str(e))
halyn/engine.py ADDED
@@ -0,0 +1,222 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ Engine — The nerve center.
5
+
6
+ Routes actions to tools. Enforces policy. Writes audit.
7
+ Every side effect passes through here. No exceptions.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import hashlib
14
+ import json
15
+ import logging
16
+ import time
17
+ from typing import Any, Callable, Awaitable
18
+
19
+ from .types import (
20
+ Action, Result, AuditEntry, Node, NodeKind,
21
+ ToolSpec, ToolCategory, ActionStatus, PolicyRule,
22
+ )
23
+
24
+ log = logging.getLogger("jarvis.engine")
25
+
26
+ ToolFn = Callable[[dict[str, Any], Node | None], Any | Awaitable[Any]]
27
+
28
+
29
+ class Registry:
30
+ """All tools, all nodes, one place. Thread-safe by design (async single-thread)."""
31
+
32
+ __slots__ = ("_tools", "_tool_fns", "_nodes", "_policies")
33
+
34
+ def __init__(self) -> None:
35
+ self._tools: dict[str, ToolSpec] = {}
36
+ self._tool_fns: dict[str, ToolFn] = {}
37
+ self._nodes: dict[str, Node] = {}
38
+ self._policies: list[PolicyRule] = []
39
+
40
+ def register_tool(
41
+ self,
42
+ name: str,
43
+ fn: ToolFn,
44
+ category: ToolCategory = ToolCategory.EXECUTOR,
45
+ description: str = "",
46
+ dangerous: bool = False,
47
+ ) -> None:
48
+ self._tools[name] = ToolSpec(name, category, description, dangerous)
49
+ self._tool_fns[name] = fn
50
+
51
+ def register_node(self, node: Node) -> None:
52
+ self._nodes[node.name] = node
53
+ log.info("node.registered name=%s kind=%s host=%s", node.name, node.kind.value, node.host)
54
+
55
+ def add_policy(self, rule: PolicyRule) -> None:
56
+ self._policies.append(rule)
57
+
58
+ @property
59
+ def tool_names(self) -> list[str]:
60
+ return list(self._tools)
61
+
62
+ @property
63
+ def nodes(self) -> dict[str, Node]:
64
+ return dict(self._nodes)
65
+
66
+ def get_tool_fn(self, name: str) -> ToolFn | None:
67
+ return self._tool_fns.get(name)
68
+
69
+ def get_node(self, name: str) -> Node | None:
70
+ return self._nodes.get(name)
71
+
72
+ def get_spec(self, name: str) -> ToolSpec | None:
73
+ return self._tools.get(name)
74
+
75
+
76
+ class AuditLog:
77
+ """Append-only, hash-chained audit trail."""
78
+
79
+ __slots__ = ("_entries", "_last_hash")
80
+
81
+ def __init__(self) -> None:
82
+ self._entries: list[AuditEntry] = []
83
+ self._last_hash: str = "0" * 64
84
+
85
+ def append(self, entry: AuditEntry) -> None:
86
+ entry.prev_hash = self._last_hash
87
+ payload = f"{entry.timestamp}:{entry.tool}:{entry.node}:{entry.status}:{entry.prev_hash}"
88
+ entry.entry_hash = hashlib.sha256(payload.encode()).hexdigest()
89
+ self._last_hash = entry.entry_hash
90
+ self._entries.append(entry)
91
+ # Bound memory — keep last 10K entries in-memory, older go to disk
92
+ if len(self._entries) > 10_000:
93
+ self._entries = self._entries[-5_000:]
94
+
95
+ def recent(self, n: int = 50) -> list[AuditEntry]:
96
+ return self._entries[-n:]
97
+
98
+ @property
99
+ def count(self) -> int:
100
+ return len(self._entries)
101
+
102
+ def verify_chain(self) -> bool:
103
+ """Verify integrity of the audit chain."""
104
+ for i in range(1, len(self._entries)):
105
+ if self._entries[i].prev_hash != self._entries[i - 1].entry_hash:
106
+ return False
107
+ return True
108
+
109
+
110
+ class Shield:
111
+ """Security middleware. Blocks dangerous commands before execution."""
112
+
113
+ DESTRUCTIVE_PATTERNS: tuple[str, ...] = (
114
+ "rm -rf", "rm -r /", "mkfs", "dd if=", "format ",
115
+ "DROP TABLE", "DROP DATABASE", "TRUNCATE", "DELETE FROM",
116
+ "shutdown", "reboot", "halt", "init 0",
117
+ "chmod 777 /", "chown", "> /dev/sd",
118
+ )
119
+
120
+ def check(self, action: Action, spec: ToolSpec | None) -> ActionStatus | None:
121
+ """Returns ActionStatus.DENIED if blocked, None if allowed."""
122
+ if spec and spec.dangerous:
123
+ log.warning("shield.dangerous tool=%s node=%s", action.tool, action.node)
124
+
125
+ cmd = action.args.get("command", "") + action.args.get("path", "")
126
+ cmd_lower = cmd.lower()
127
+
128
+ for pattern in self.DESTRUCTIVE_PATTERNS:
129
+ if pattern.lower() in cmd_lower:
130
+ log.warning("shield.blocked pattern=%s cmd=%s", pattern, cmd[:100])
131
+ return ActionStatus.DENIED
132
+
133
+ return None
134
+
135
+
136
+ class Engine:
137
+ """The single entry point for all actions. Routes, guards, executes, audits."""
138
+
139
+ __slots__ = ("registry", "audit", "shield", "_started")
140
+
141
+ def __init__(self) -> None:
142
+ self.registry = Registry()
143
+ self.audit = AuditLog()
144
+ self.shield = Shield()
145
+ self._started = time.monotonic()
146
+
147
+ async def execute(self, action: Action, user: str = "default") -> Result:
148
+ """Execute one action. The only way to do anything."""
149
+ t0 = time.monotonic()
150
+
151
+ # 1. Resolve tool
152
+ fn = self.registry.get_tool_fn(action.tool)
153
+ if fn is None:
154
+ return self._fail(action, f"unknown tool: {action.tool}", t0, user)
155
+
156
+ spec = self.registry.get_spec(action.tool)
157
+
158
+ # 2. Resolve node
159
+ node = self.registry.get_node(action.node)
160
+ if action.node != "local" and node is None:
161
+ return self._fail(action, f"unknown node: {action.node}", t0, user)
162
+
163
+ # 3. Shield check
164
+ verdict = self.shield.check(action, spec)
165
+ if verdict is not None:
166
+ return self._result(action, verdict, None, "blocked by shield", t0, user)
167
+
168
+ # 4. Execute
169
+ try:
170
+ if asyncio.iscoroutinefunction(fn):
171
+ data = await fn(action.args, node)
172
+ else:
173
+ data = fn(action.args, node)
174
+ result = self._result(action, ActionStatus.OK, data, "", t0, user)
175
+
176
+ except TimeoutError:
177
+ result = self._result(action, ActionStatus.TIMEOUT, None, "timeout", t0, user)
178
+ except Exception as exc:
179
+ result = self._result(action, ActionStatus.FAILED, None, str(exc)[:500], t0, user)
180
+
181
+ return result
182
+
183
+ async def batch(self, actions: list[Action], user: str = "default") -> list[Result]:
184
+ """Execute multiple actions sequentially. Future: parallel with DAG."""
185
+ return [await self.execute(a, user) for a in actions]
186
+
187
+ def health(self) -> dict[str, Any]:
188
+ uptime = time.monotonic() - self._started
189
+ return {
190
+ "status": "ok",
191
+ "uptime_s": round(uptime, 1),
192
+ "tools": len(self.registry.tool_names),
193
+ "nodes": len(self.registry.nodes),
194
+ "actions_total": self.audit.count,
195
+ "audit_chain_valid": self.audit.verify_chain(),
196
+ }
197
+
198
+ # ─── Internal ──────────────────────────────────
199
+
200
+ def _result(
201
+ self, action: Action, status: ActionStatus,
202
+ data: Any, error: str, t0: float, user: str,
203
+ ) -> Result:
204
+ elapsed = (time.monotonic() - t0) * 1000
205
+ result = Result(
206
+ status=status, data=data, error=error,
207
+ elapsed_ms=round(elapsed, 2),
208
+ node=action.node, tool=action.tool,
209
+ )
210
+ self.audit.append(AuditEntry(
211
+ tool=action.tool, node=action.node,
212
+ status=status.value, elapsed_ms=result.elapsed_ms,
213
+ user=user, error=error[:200] if error else "",
214
+ ))
215
+ lvl = logging.INFO if result.ok else logging.WARNING
216
+ log.log(lvl, "action.%s tool=%s node=%s ms=%.1f",
217
+ status.value, action.tool, action.node, elapsed)
218
+ return result
219
+
220
+ def _fail(self, action: Action, error: str, t0: float, user: str) -> Result:
221
+ return self._result(action, ActionStatus.FAILED, None, error, t0, user)
222
+