anna-app-runtime-local 0.2.0a1__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.
@@ -0,0 +1,29 @@
1
+ """Local-dev in-memory runtime for Anna Apps.
2
+
3
+ See README.md for architecture notes.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .executa import ExecutaPool, ExecutaSpec
9
+ from .session import LocalDispatcherSession, LocalSessionError
10
+ from .store import InMemoryWindowSession, InMemoryWindowStore
11
+ from .token import (
12
+ DevTokenError,
13
+ mint_dev_token,
14
+ verify_dev_token,
15
+ )
16
+
17
+ __all__ = [
18
+ "LocalDispatcherSession",
19
+ "LocalSessionError",
20
+ "InMemoryWindowStore",
21
+ "InMemoryWindowSession",
22
+ "ExecutaPool",
23
+ "ExecutaSpec",
24
+ "mint_dev_token",
25
+ "verify_dev_token",
26
+ "DevTokenError",
27
+ ]
28
+
29
+ __version__ = "0.1.0"
@@ -0,0 +1,236 @@
1
+ """Stdio JSON-RPC 2.0 bridge for the anna-app-cli harness.
2
+
3
+ Spoken by `anna-app-cli/src/harness/bridge.ts` over `python-shell`.
4
+
5
+ Protocol: one JSON envelope per line on stdin / stdout.
6
+
7
+ Methods (request → response):
8
+
9
+ session.create { user_id, manifest, view?, entry_payload?, app_slug? }
10
+ → { session_id, window_uuid, token, expires_in }
11
+
12
+ session.call { session_id, ns, method, args? }
13
+ → { ok: true, result: <dict> }
14
+ | { ok: false, error: { code, message, details } }
15
+
16
+ session.drain_events { session_id }
17
+ → { events: [<event>, ...] }
18
+
19
+ session.close { session_id }
20
+ → { ok: true }
21
+
22
+ session.refresh_token { session_id }
23
+ → { token, expires_in }
24
+
25
+ executas.register { executas: [{tool_id, project_dir, command?}, ...] }
26
+ → { registered: [tool_id, ...] }
27
+
28
+ ping {} → { pong: true }
29
+
30
+ All other request methods → JSON-RPC error -32601 (Method not found).
31
+ Malformed envelopes → -32700.
32
+
33
+ Exit cleanly on EOF.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import asyncio
39
+ import json
40
+ import sys
41
+ import traceback
42
+ from typing import Any
43
+
44
+ from .executa import ExecutaPool, ExecutaSpec
45
+ from .session import LocalDispatcherSession, LocalSessionError
46
+ from .store import InMemoryWindowStore
47
+ from .token import mint_dev_token
48
+
49
+
50
+ class _Bridge:
51
+ def __init__(self) -> None:
52
+ self._sessions: dict[str, LocalDispatcherSession] = {}
53
+ self._next_id = 1
54
+ # Process-wide singletons: one executa pool + one in-memory store
55
+ # shared by every harness session (mirrors how production has a
56
+ # single dispatcher per user).
57
+ self._executa_pool = ExecutaPool()
58
+ self._store = InMemoryWindowStore(executa_pool=self._executa_pool)
59
+
60
+ def _new_session_id(self) -> str:
61
+ sid = f"sess-{self._next_id}"
62
+ self._next_id += 1
63
+ return sid
64
+
65
+ async def handle(self, req: dict[str, Any]) -> dict[str, Any]:
66
+ method = req.get("method")
67
+ params = req.get("params") or {}
68
+ rpc_id = req.get("id")
69
+
70
+ try:
71
+ if method == "ping":
72
+ result: Any = {"pong": True}
73
+
74
+ elif method == "session.create":
75
+ session = LocalDispatcherSession.create(
76
+ user_id=int(params.get("user_id", 1)),
77
+ manifest_dict=params["manifest"],
78
+ view=params.get("view"),
79
+ entry_payload=params.get("entry_payload"),
80
+ runtime_state=params.get("runtime_state"),
81
+ app_slug=params.get("app_slug"),
82
+ store=self._store,
83
+ )
84
+ sid = self._new_session_id()
85
+ self._sessions[sid] = session
86
+ token, expires_in = mint_dev_token(
87
+ window_uuid=session.window_uuid,
88
+ user_id=session.window.user_id,
89
+ app_id=session.window.app_id,
90
+ version_id=session.window.version_id,
91
+ )
92
+ result = {
93
+ "session_id": sid,
94
+ "window_uuid": session.window_uuid,
95
+ "token": token,
96
+ "expires_in": expires_in,
97
+ "view": session.window.view,
98
+ "view_meta": _view_meta(session),
99
+ }
100
+
101
+ elif method == "session.call":
102
+ sid = params["session_id"]
103
+ session = self._sessions.get(sid)
104
+ if session is None:
105
+ return _err(rpc_id, -32602, f"unknown session_id: {sid}")
106
+ try:
107
+ out = await session.call(
108
+ params["ns"],
109
+ params["method"],
110
+ params.get("args") or {},
111
+ )
112
+ result = {"ok": True, "result": out}
113
+ except LocalSessionError as e:
114
+ result = {"ok": False, "error": e.to_dict()}
115
+
116
+ elif method == "session.drain_events":
117
+ sid = params["session_id"]
118
+ session = self._sessions.get(sid)
119
+ if session is None:
120
+ return _err(rpc_id, -32602, f"unknown session_id: {sid}")
121
+ result = {"events": session.drain_events()}
122
+
123
+ elif method == "session.close":
124
+ sid = params["session_id"]
125
+ self._sessions.pop(sid, None)
126
+ result = {"ok": True}
127
+
128
+ elif method == "session.refresh_token":
129
+ sid = params["session_id"]
130
+ session = self._sessions.get(sid)
131
+ if session is None:
132
+ return _err(rpc_id, -32602, f"unknown session_id: {sid}")
133
+ token, expires_in = mint_dev_token(
134
+ window_uuid=session.window_uuid,
135
+ user_id=session.window.user_id,
136
+ app_id=session.window.app_id,
137
+ version_id=session.window.version_id,
138
+ )
139
+ result = {"token": token, "expires_in": expires_in}
140
+
141
+ elif method == "executas.register":
142
+ # params: {executas: [{tool_id, project_dir, command?}, ...]}
143
+ items = params.get("executas") or []
144
+ registered: list[str] = []
145
+ for it in items:
146
+ spec = ExecutaSpec(
147
+ tool_id=it["tool_id"],
148
+ project_dir=it["project_dir"],
149
+ command=it.get("command"),
150
+ )
151
+ self._executa_pool.register(spec)
152
+ registered.append(spec.tool_id)
153
+ result = {"registered": registered}
154
+
155
+ else:
156
+ return _err(rpc_id, -32601, f"method not found: {method}")
157
+
158
+ return {"jsonrpc": "2.0", "id": rpc_id, "result": result}
159
+
160
+ except KeyError as e:
161
+ return _err(rpc_id, -32602, f"missing required param: {e}")
162
+ except Exception as e: # pragma: no cover — defensive
163
+ return _err(
164
+ rpc_id,
165
+ -32603,
166
+ f"internal: {e}",
167
+ {"traceback": traceback.format_exc()},
168
+ )
169
+
170
+
171
+ def _err(
172
+ rpc_id: Any, code: int, message: str, data: dict | None = None
173
+ ) -> dict[str, Any]:
174
+ err: dict[str, Any] = {"code": code, "message": message}
175
+ if data:
176
+ err["data"] = data
177
+ return {"jsonrpc": "2.0", "id": rpc_id, "error": err}
178
+
179
+
180
+ def _view_meta(session: LocalDispatcherSession) -> dict[str, Any] | None:
181
+ """Return the chosen view's metadata as a plain dict (for harness sizing)."""
182
+ ui = session.manifest.ui
183
+ if ui is None:
184
+ return None
185
+ for v in ui.views:
186
+ if v.name == session.window.view:
187
+ return v.model_dump(mode="json", exclude_none=True)
188
+ return None
189
+
190
+
191
+ async def _run() -> None:
192
+ bridge = _Bridge()
193
+ loop = asyncio.get_event_loop()
194
+ reader = asyncio.StreamReader()
195
+ protocol = asyncio.StreamReaderProtocol(reader)
196
+ await loop.connect_read_pipe(lambda: protocol, sys.stdin)
197
+
198
+ # Announce readiness so the Node side can start sending.
199
+ sys.stdout.write(
200
+ json.dumps({"jsonrpc": "2.0", "method": "_ready", "params": {}}) + "\n"
201
+ )
202
+ sys.stdout.flush()
203
+
204
+ while True:
205
+ line = await reader.readline()
206
+ if not line:
207
+ return
208
+ text = line.decode("utf-8").strip()
209
+ if not text:
210
+ continue
211
+ try:
212
+ req = json.loads(text)
213
+ except json.JSONDecodeError:
214
+ sys.stdout.write(json.dumps(_err(None, -32700, "parse error")) + "\n")
215
+ sys.stdout.flush()
216
+ continue
217
+ if not isinstance(req, dict):
218
+ sys.stdout.write(
219
+ json.dumps(_err(None, -32600, "request must be an object")) + "\n"
220
+ )
221
+ sys.stdout.flush()
222
+ continue
223
+ resp = await bridge.handle(req)
224
+ sys.stdout.write(json.dumps(resp) + "\n")
225
+ sys.stdout.flush()
226
+
227
+
228
+ def main() -> None:
229
+ try:
230
+ asyncio.run(_run())
231
+ except KeyboardInterrupt:
232
+ pass
233
+
234
+
235
+ if __name__ == "__main__":
236
+ main()
@@ -0,0 +1,223 @@
1
+ """Stdio JSON-RPC client for Executa plugins (used by the harness).
2
+
3
+ Mirrors the contract that Anna's NATS-side Agent uses when it spawns a
4
+ plugin via the minted `[project.scripts]` entry: the plugin reads one
5
+ JSON envelope per line on stdin and writes one per line on stdout.
6
+
7
+ Methods spoken to the plugin:
8
+
9
+ describe {} → plugin MANIFEST
10
+ invoke {tool: <method>, arguments: <dict>} → {success, data | error, ...}
11
+ health {} → {status: "ok", ...}
12
+
13
+ The harness reuses one warm subprocess per `tool_id` for the lifetime of
14
+ the dev session. First call lazy-spawns; subsequent calls reuse the same
15
+ process. The pool is keyed on `tool_id` because the plugin's executable
16
+ is named after the minted id.
17
+
18
+ The class is intentionally tiny — it does NOT mock NATS retry / Agent
19
+ credential resolution. Production handlers wrap NATS responses in an
20
+ extra envelope; we do the same here so the dispatcher's two-layer
21
+ unwrap (`call_rpc → InvokeResult`) works unchanged.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import json
28
+ import sys
29
+ from dataclasses import dataclass, field
30
+ from typing import Any
31
+
32
+
33
+ @dataclass
34
+ class ExecutaSpec:
35
+ """How to launch one Executa plugin from the harness."""
36
+
37
+ tool_id: str
38
+ project_dir: str
39
+ # Optional override; default = ["uv","run","--project",project_dir,tool_id]
40
+ command: list[str] | None = None
41
+
42
+
43
+ @dataclass
44
+ class _ExecutaProcess:
45
+ spec: ExecutaSpec
46
+ proc: asyncio.subprocess.Process
47
+ next_id: int = 1
48
+ pending: dict[int, asyncio.Future] = field(default_factory=dict)
49
+ reader_task: asyncio.Task | None = None
50
+ closed: bool = False
51
+
52
+
53
+ class ExecutaPool:
54
+ """Lazy pool of `_ExecutaProcess` instances keyed by `tool_id`."""
55
+
56
+ def __init__(self, specs: dict[str, ExecutaSpec] | None = None) -> None:
57
+ self._specs: dict[str, ExecutaSpec] = dict(specs or {})
58
+ self._procs: dict[str, _ExecutaProcess] = {}
59
+ # Serialise spawns so concurrent first-calls don't double-spawn.
60
+ self._spawn_lock = asyncio.Lock()
61
+
62
+ def register(self, spec: ExecutaSpec) -> None:
63
+ self._specs[spec.tool_id] = spec
64
+
65
+ def is_registered(self, tool_id: str) -> bool:
66
+ return tool_id in self._specs
67
+
68
+ async def invoke(
69
+ self, *, tool_id: str, tool_name: str, tool_args: dict
70
+ ) -> dict[str, Any]:
71
+ """Send `invoke` to the plugin and return the *outer* envelope.
72
+
73
+ Outer shape mirrors what `nexus.tools.nats_rpc_base.call_rpc()`
74
+ returns in production:
75
+ {"success": True/False, "data": <plugin_result>,
76
+ "command_id": "local"}
77
+ which the dispatcher then unwraps.
78
+ """
79
+ proc = await self._get_or_spawn(tool_id)
80
+ try:
81
+ inner = await self._rpc(
82
+ proc,
83
+ method="invoke",
84
+ params={"tool": tool_name, "arguments": tool_args},
85
+ )
86
+ except _PluginError as e:
87
+ # Plugin returned a JSON-RPC error envelope; treat as
88
+ # NATS-layer success carrying a tool-layer failure so the
89
+ # dispatcher's two-layer unwrap surfaces a `tool_failed`.
90
+ return {
91
+ "success": True,
92
+ "data": {"success": False, "error": str(e)},
93
+ "command_id": "local",
94
+ }
95
+ # The plugin's own contract is `{success, data | error, ...}`.
96
+ return {"success": True, "data": inner, "command_id": "local"}
97
+
98
+ async def shutdown(self) -> None:
99
+ for tool_id, p in list(self._procs.items()):
100
+ await self._kill(p)
101
+ del self._procs[tool_id]
102
+
103
+ # -- internals -----------------------------------------------------------
104
+
105
+ async def _get_or_spawn(self, tool_id: str) -> _ExecutaProcess:
106
+ existing = self._procs.get(tool_id)
107
+ if existing and not existing.closed:
108
+ return existing
109
+ async with self._spawn_lock:
110
+ existing = self._procs.get(tool_id)
111
+ if existing and not existing.closed:
112
+ return existing
113
+ spec = self._specs.get(tool_id)
114
+ if spec is None:
115
+ # Mirror dispatcher's HostRpcError signal but stay layer-clean.
116
+ raise KeyError(
117
+ f"executa '{tool_id}' is not registered with the harness"
118
+ )
119
+ cmd = spec.command or [
120
+ "uv",
121
+ "run",
122
+ "--project",
123
+ spec.project_dir,
124
+ tool_id,
125
+ ]
126
+ proc = await asyncio.create_subprocess_exec(
127
+ *cmd,
128
+ stdin=asyncio.subprocess.PIPE,
129
+ stdout=asyncio.subprocess.PIPE,
130
+ stderr=asyncio.subprocess.PIPE,
131
+ cwd=spec.project_dir,
132
+ )
133
+ ep = _ExecutaProcess(spec=spec, proc=proc)
134
+ ep.reader_task = asyncio.create_task(self._reader_loop(ep))
135
+ asyncio.create_task(self._stderr_loop(ep))
136
+ self._procs[tool_id] = ep
137
+ return ep
138
+
139
+ async def _rpc(
140
+ self,
141
+ ep: _ExecutaProcess,
142
+ *,
143
+ method: str,
144
+ params: dict,
145
+ timeout: float = 65.0,
146
+ ) -> Any:
147
+ rpc_id = ep.next_id
148
+ ep.next_id += 1
149
+ env = {"jsonrpc": "2.0", "id": rpc_id, "method": method, "params": params}
150
+ fut: asyncio.Future = asyncio.get_event_loop().create_future()
151
+ ep.pending[rpc_id] = fut
152
+ assert ep.proc.stdin is not None
153
+ ep.proc.stdin.write((json.dumps(env) + "\n").encode("utf-8"))
154
+ await ep.proc.stdin.drain()
155
+ try:
156
+ return await asyncio.wait_for(fut, timeout=timeout)
157
+ except asyncio.TimeoutError:
158
+ ep.pending.pop(rpc_id, None)
159
+ raise _PluginError(f"executa '{ep.spec.tool_id}' timed out")
160
+
161
+ async def _reader_loop(self, ep: _ExecutaProcess) -> None:
162
+ assert ep.proc.stdout is not None
163
+ try:
164
+ while True:
165
+ line = await ep.proc.stdout.readline()
166
+ if not line:
167
+ break
168
+ try:
169
+ msg = json.loads(line.decode("utf-8"))
170
+ except Exception:
171
+ continue # ignore non-JSON noise
172
+ rpc_id = msg.get("id")
173
+ fut = ep.pending.pop(rpc_id, None) if rpc_id is not None else None
174
+ if fut is None or fut.done():
175
+ continue
176
+ if "error" in msg:
177
+ err = msg["error"] or {}
178
+ fut.set_exception(
179
+ _PluginError(
180
+ f"[{err.get('code', '?')}] {err.get('message', 'unknown error')}"
181
+ )
182
+ )
183
+ else:
184
+ fut.set_result(msg.get("result"))
185
+ finally:
186
+ ep.closed = True
187
+ for fut in ep.pending.values():
188
+ if not fut.done():
189
+ fut.set_exception(_PluginError("executa process exited"))
190
+ ep.pending.clear()
191
+
192
+ async def _stderr_loop(self, ep: _ExecutaProcess) -> None:
193
+ assert ep.proc.stderr is not None
194
+ prefix = f"[executa:{ep.spec.tool_id}]"
195
+ while True:
196
+ line = await ep.proc.stderr.readline()
197
+ if not line:
198
+ break
199
+ sys.stderr.write(f"{prefix} {line.decode('utf-8', 'replace')}")
200
+
201
+ async def _kill(self, ep: _ExecutaProcess) -> None:
202
+ try:
203
+ if ep.proc.stdin is not None:
204
+ ep.proc.stdin.close()
205
+ except Exception:
206
+ pass
207
+ try:
208
+ ep.proc.terminate()
209
+ except Exception:
210
+ pass
211
+ try:
212
+ await asyncio.wait_for(ep.proc.wait(), timeout=2.0)
213
+ except asyncio.TimeoutError:
214
+ try:
215
+ ep.proc.kill()
216
+ except Exception:
217
+ pass
218
+
219
+
220
+ class _PluginError(Exception):
221
+ """Internal: plugin returned JSON-RPC error or timed out."""
222
+
223
+ pass
@@ -0,0 +1,114 @@
1
+ """High-level session API: glue between an `InMemoryWindowStore` and the
2
+ production `dispatch()` function.
3
+
4
+ Each `LocalDispatcherSession` owns one `InMemoryWindowSession` (= one
5
+ iframe instance). The harness creates one per `(slug, view)` pair. The
6
+ session also holds the parsed `AppManifest` so every `call()` re-uses
7
+ the same authoritative ACL.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import uuid
13
+ from typing import Any
14
+
15
+ from .store import InMemoryWindowSession, InMemoryWindowStore
16
+
17
+
18
+ class LocalSessionError(Exception):
19
+ """Wraps `HostRpcError` from production dispatcher with structured info."""
20
+
21
+ def __init__(self, code: str, message: str, details: dict | None = None):
22
+ super().__init__(message)
23
+ self.code = code
24
+ self.message = message
25
+ self.details = details or {}
26
+
27
+ def to_dict(self) -> dict[str, Any]:
28
+ return {"code": self.code, "message": self.message, "details": self.details}
29
+
30
+
31
+ class LocalDispatcherSession:
32
+ """One harness window. Construct via `LocalDispatcherSession.create(…)`."""
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ store: InMemoryWindowStore,
38
+ window: InMemoryWindowSession,
39
+ manifest: Any, # AppManifest, but typed loosely to avoid early import
40
+ ):
41
+ self.store = store
42
+ self.window = window
43
+ self.manifest = manifest
44
+
45
+ @property
46
+ def window_uuid(self) -> str:
47
+ return self.window.window_uuid
48
+
49
+ @classmethod
50
+ def create(
51
+ cls,
52
+ *,
53
+ user_id: int,
54
+ manifest_dict: dict,
55
+ view: str | None = None,
56
+ entry_payload: dict | None = None,
57
+ runtime_state: dict | None = None,
58
+ app_id: int = 1,
59
+ version_id: int = 1,
60
+ bundle_id: int = 1,
61
+ app_slug: str | None = None,
62
+ store: InMemoryWindowStore | None = None,
63
+ ) -> "LocalDispatcherSession":
64
+ from anna_app_core import AppManifest
65
+
66
+ manifest = AppManifest.model_validate(manifest_dict)
67
+
68
+ if store is None:
69
+ store = InMemoryWindowStore()
70
+ if user_id not in (store._users or {}):
71
+ store.register_user(user_id)
72
+
73
+ # Default view = first one with default=True, else first.
74
+ chosen_view = view
75
+ if chosen_view is None and manifest.ui is not None:
76
+ for v in manifest.ui.views:
77
+ if v.default:
78
+ chosen_view = v.name
79
+ break
80
+ if chosen_view is None and manifest.ui.views:
81
+ chosen_view = manifest.ui.views[0].name
82
+
83
+ window = InMemoryWindowSession(
84
+ window_uuid=str(uuid.uuid4()),
85
+ user_id=user_id,
86
+ app_id=app_id,
87
+ version_id=version_id,
88
+ bundle_id=bundle_id,
89
+ view=chosen_view or "main",
90
+ entry_payload=dict(entry_payload or {}),
91
+ runtime_state=dict(runtime_state or {}),
92
+ app_slug=app_slug,
93
+ )
94
+ store.attach_session(window)
95
+ return cls(store=store, window=window, manifest=manifest)
96
+
97
+ async def call(self, ns: str, method: str, args: dict | None = None) -> dict:
98
+ """Dispatch one envelope. Raises `LocalSessionError` on host errors."""
99
+ from anna_app_core import HostRpcError, dispatch
100
+
101
+ try:
102
+ return await dispatch(
103
+ self.store,
104
+ window=self.window, # type: ignore[arg-type]
105
+ manifest=self.manifest,
106
+ ns=ns,
107
+ method=method,
108
+ args=args or {},
109
+ )
110
+ except HostRpcError as e:
111
+ raise LocalSessionError(e.code, e.message, e.details) from e
112
+
113
+ def drain_events(self) -> list[dict[str, Any]]:
114
+ return self.store.drain_events()
@@ -0,0 +1,178 @@
1
+ """In-memory `WindowStoreProtocol` implementation for local dev / tests.
2
+
3
+ Mirrors the attributes that production's `AnnaAppWindowSession` exposes to
4
+ the dispatcher handlers: see `src/models/anna_app.py` (`window.user_id`,
5
+ `window.window_uuid`, `window.title`, `window.runtime_state`, etc.).
6
+
7
+ Heavy operations (`open_sibling_window`, `invoke_executa_tool`) raise
8
+ `NotImplementedError` for the 3a slice — handlers translate that into
9
+ `HostRpcError("not_implemented", …)`. A future 3b will plug a stdio
10
+ plugin runner into `invoke_executa_tool`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from collections import deque
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime
19
+ from typing import Any, Deque, Optional
20
+
21
+ # Keep this in sync with `src/services/anna_app_runtime_service.py`
22
+ # (`MAX_RUNTIME_STATE_BYTES`). We do not import it because the wheel must
23
+ # remain runnable without nexus settings (which require env vars).
24
+ MAX_RUNTIME_STATE_BYTES = 256 * 1024
25
+
26
+
27
+ @dataclass
28
+ class _FakeUser:
29
+ """Minimal stand-in for `src.models.user.User` returned by `get_user()`.
30
+
31
+ Production handlers only read `user.id` (and `default_agent_client_id`
32
+ inside `tools.invoke`, which the in-memory store stubs out anyway).
33
+ """
34
+
35
+ id: int
36
+ default_agent_client_id: Optional[str] = None
37
+
38
+
39
+ @dataclass
40
+ class InMemoryWindowSession:
41
+ """Mirror of `AnnaAppWindowSession` for the harness.
42
+
43
+ Field set is the strict subset that the production dispatcher handlers
44
+ read or write. Adding a field here when the dispatcher starts using a
45
+ new attribute is the supported way to keep the harness in sync.
46
+ """
47
+
48
+ window_uuid: str
49
+ user_id: int
50
+ app_id: int
51
+ version_id: int
52
+ bundle_id: int
53
+ view: str
54
+ title: Optional[str] = None
55
+ entry_payload: dict = field(default_factory=dict)
56
+ runtime_state: dict = field(default_factory=dict)
57
+ geometry: dict = field(default_factory=dict)
58
+ status: str = "active"
59
+ source: str = "harness"
60
+ conversation_session_uuid: Optional[str] = None
61
+ last_focus_at: datetime = field(default_factory=datetime.utcnow)
62
+ closed_reason: Optional[str] = None
63
+ closed_at: Optional[datetime] = None
64
+ app_slug: Optional[str] = None # harness-only, helpful for logging
65
+
66
+
67
+ class InMemoryWindowStore:
68
+ """`WindowStoreProtocol` impl backed by Python dicts + a deque of events.
69
+
70
+ Not thread-safe; the harness runs everything on a single asyncio loop.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ *,
76
+ users: dict[int, _FakeUser] | None = None,
77
+ executa_pool: Any | None = None,
78
+ ):
79
+ self._users: dict[int, _FakeUser] = users or {}
80
+ self._events: Deque[dict[str, Any]] = deque()
81
+ self._committed = 0 # for tests
82
+ # Optional `ExecutaPool` for `tools.invoke`. Lazy-imported to keep the
83
+ # module load light when only window/storage tests run.
84
+ self._executa_pool = executa_pool
85
+
86
+ def set_executa_pool(self, pool: Any) -> None:
87
+ self._executa_pool = pool
88
+
89
+ # ---- helper API used by the bridge -------------------------------------
90
+
91
+ def register_user(
92
+ self, user_id: int, *, default_agent_client_id: str | None = None
93
+ ) -> _FakeUser:
94
+ u = _FakeUser(id=user_id, default_agent_client_id=default_agent_client_id)
95
+ self._users[user_id] = u
96
+ return u
97
+
98
+ def drain_events(self) -> list[dict[str, Any]]:
99
+ out = list(self._events)
100
+ self._events.clear()
101
+ return out
102
+
103
+ @property
104
+ def commit_count(self) -> int:
105
+ return self._committed
106
+
107
+ # ---- WindowStoreProtocol -----------------------------------------------
108
+
109
+ async def commit(self) -> None:
110
+ self._committed += 1
111
+
112
+ async def get_user(self, user_id: int) -> _FakeUser | None:
113
+ return self._users.get(user_id)
114
+
115
+ async def replace_runtime_state(
116
+ self, *, user: Any, window_uuid: str, state: dict
117
+ ) -> None:
118
+ # Mirror of `runtime.replace_runtime_state`'s size guard.
119
+ from anna_app_core import WindowError
120
+
121
+ encoded = json.dumps(state).encode("utf-8")
122
+ if len(encoded) > MAX_RUNTIME_STATE_BYTES:
123
+ raise WindowError(
124
+ f"runtime_state exceeds {MAX_RUNTIME_STATE_BYTES} bytes "
125
+ f"(got {len(encoded)})"
126
+ )
127
+ # Mutate the live `InMemoryWindowSession` for this window.
128
+ for sess in self._sessions_iter():
129
+ if sess.window_uuid == window_uuid:
130
+ sess.runtime_state = dict(state)
131
+ return
132
+ raise WindowError(f"window {window_uuid} not registered with store")
133
+
134
+ async def emit_event(
135
+ self, user_id: int, kind: str, payload: dict[str, Any]
136
+ ) -> None:
137
+ self._events.append(
138
+ {
139
+ "user_id": user_id,
140
+ "kind": kind,
141
+ "payload": payload,
142
+ "ts": datetime.utcnow().isoformat() + "Z",
143
+ }
144
+ )
145
+
146
+ async def open_sibling_window(self, *, window, view_name, payload):
147
+ raise NotImplementedError(
148
+ "open_sibling_window is not available in the local harness "
149
+ "(slice 3a). Planned for 3b."
150
+ )
151
+
152
+ async def invoke_executa_tool(self, *, user_id, plugin_name, tool_name, tool_args):
153
+ # `plugin_name` carries the dispatcher's resolved plugin segment
154
+ # which equals the minted `tool_id` for mint-only ids.
155
+ if self._executa_pool is None or not self._executa_pool.is_registered(
156
+ plugin_name
157
+ ):
158
+ raise NotImplementedError(
159
+ f"executa '{plugin_name}' is not registered with the harness"
160
+ )
161
+ return await self._executa_pool.invoke(
162
+ tool_id=plugin_name, tool_name=tool_name, tool_args=tool_args
163
+ )
164
+
165
+ # ---- session registration ---------------------------------------------
166
+
167
+ # The session module attaches a list of `InMemoryWindowSession` to the
168
+ # store via this attribute. Kept private so the public API stays small.
169
+
170
+ _sessions: list[InMemoryWindowSession] | None = None
171
+
172
+ def attach_session(self, sess: InMemoryWindowSession) -> None:
173
+ if self._sessions is None:
174
+ self._sessions = []
175
+ self._sessions.append(sess)
176
+
177
+ def _sessions_iter(self):
178
+ return list(self._sessions or [])
@@ -0,0 +1,113 @@
1
+ """Local HMAC dev tokens (NOT interoperable with production JWTs).
2
+
3
+ Production uses a JWT signed with the nexus signing key (see
4
+ `mint_window_token` in `src/services/anna_app_runtime_service.py`). The
5
+ harness deliberately uses a separate scheme so:
6
+
7
+ - The bridge does not need access to nexus settings / signing keys.
8
+ - A leaked harness token can never authenticate against production.
9
+ - Production's `_is_tool_allowed` already rejects `tool-dev-…` prefixes,
10
+ closing the loop on dev mints accidentally being uploaded.
11
+
12
+ Format: base64url(payload_json) + "." + base64url(hmac_sha256(payload, key))
13
+
14
+ The key lives at `~/.anna-app/dev.key` (mode 600, generated on first
15
+ use). TTL defaults to 30 s to surface SDK refresh bugs early.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import hashlib
22
+ import hmac
23
+ import json
24
+ import os
25
+ import secrets
26
+ import stat
27
+ import time
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ DEFAULT_TTL_SECONDS = 30
32
+ _DEV_KEY_PATH = Path(os.environ.get("ANNA_APP_DEV_KEY") or "~/.anna-app/dev.key")
33
+
34
+
35
+ class DevTokenError(ValueError):
36
+ """Raised on malformed / expired / signature-mismatched tokens."""
37
+
38
+
39
+ def _key_path() -> Path:
40
+ return _DEV_KEY_PATH.expanduser()
41
+
42
+
43
+ def _ensure_key() -> bytes:
44
+ p = _key_path()
45
+ if p.exists():
46
+ return p.read_bytes()
47
+ p.parent.mkdir(parents=True, exist_ok=True)
48
+ key = secrets.token_bytes(32)
49
+ p.write_bytes(key)
50
+ try:
51
+ os.chmod(p, stat.S_IRUSR | stat.S_IWUSR) # 0o600
52
+ except OSError:
53
+ # Windows: best-effort.
54
+ pass
55
+ return key
56
+
57
+
58
+ def _b64url_encode(b: bytes) -> str:
59
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
60
+
61
+
62
+ def _b64url_decode(s: str) -> bytes:
63
+ pad = "=" * (-len(s) % 4)
64
+ return base64.urlsafe_b64decode(s + pad)
65
+
66
+
67
+ def mint_dev_token(
68
+ *,
69
+ window_uuid: str,
70
+ user_id: int,
71
+ app_id: int = 0,
72
+ version_id: int = 0,
73
+ scopes: list[str] | None = None,
74
+ ttl_seconds: int = DEFAULT_TTL_SECONDS,
75
+ ) -> tuple[str, int]:
76
+ """Return ``(token, expires_in)``."""
77
+ now = int(time.time())
78
+ payload: dict[str, Any] = {
79
+ "iss": "anna-app-runtime-local",
80
+ "aud": "anna-app-window",
81
+ "sub": str(user_id),
82
+ "iat": now,
83
+ "exp": now + ttl_seconds,
84
+ "wid": window_uuid,
85
+ "uid": user_id,
86
+ "aid": app_id,
87
+ "vid": version_id,
88
+ "scopes": scopes or [],
89
+ }
90
+ body_b = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
91
+ sig = hmac.new(_ensure_key(), body_b, hashlib.sha256).digest()
92
+ return f"{_b64url_encode(body_b)}.{_b64url_encode(sig)}", ttl_seconds
93
+
94
+
95
+ def verify_dev_token(token: str) -> dict[str, Any]:
96
+ if not isinstance(token, str) or "." not in token:
97
+ raise DevTokenError("malformed token")
98
+ body_b64, sig_b64 = token.split(".", 1)
99
+ try:
100
+ body_b = _b64url_decode(body_b64)
101
+ sig = _b64url_decode(sig_b64)
102
+ except (ValueError, base64.binascii.Error) as e:
103
+ raise DevTokenError(f"base64 decode failed: {e}") from e
104
+ expected = hmac.new(_ensure_key(), body_b, hashlib.sha256).digest()
105
+ if not hmac.compare_digest(sig, expected):
106
+ raise DevTokenError("bad signature")
107
+ try:
108
+ payload = json.loads(body_b)
109
+ except json.JSONDecodeError as e:
110
+ raise DevTokenError(f"payload not json: {e}") from e
111
+ if int(payload.get("exp", 0)) < int(time.time()):
112
+ raise DevTokenError("token expired")
113
+ return payload
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: anna-app-runtime-local
3
+ Version: 0.2.0a1
4
+ Summary: Local-dev in-memory runtime for Anna Apps. Wraps anna-app-core's dispatcher with an InMemoryWindowStore + WebSocket bridge.
5
+ Author: Talent AI
6
+ License: Proprietary
7
+ Keywords: anna,anna-app,dev,executa,harness
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: anna-app-core<0.3,>=0.2.0a1
10
+ Description-Content-Type: text/markdown
11
+
12
+ # anna-app-runtime-local
13
+
14
+ Local-dev in-memory runtime for Anna Apps. Reuses the production
15
+ [`anna_app_rpc_dispatcher`](../../src/services/anna_app_rpc_dispatcher.py)
16
+ through `WindowStoreProtocol` so harness behaviour is byte-identical to
17
+ nexus production.
18
+
19
+ ## How it shares code with production
20
+
21
+ This package does not vendor a copy of the dispatcher. Instead it:
22
+
23
+ 1. Imports `dispatch`, `WindowStoreProtocol`, `HostRpcError` from
24
+ `src.services.anna_app_rpc_dispatcher` (matrix-nexus source).
25
+ 2. Provides `InMemoryWindowStore` — a `WindowStoreProtocol` impl that
26
+ keeps state in dictionaries and queues SSE events in memory.
27
+ 3. Wires the production `dispatch(store, …)` against in-memory state.
28
+
29
+ Because the wheel imports `src.services.…` directly, the bridge process
30
+ must be launched from a Python environment that has matrix-nexus on
31
+ `PYTHONPATH` (typically: `cd matrix-nexus && uv run python -m
32
+ anna_app_runtime_local.bridge`).
33
+
34
+ ## Public surface
35
+
36
+ ```python
37
+ from anna_app_runtime_local import LocalDispatcherSession, mint_dev_token
38
+
39
+ session = LocalDispatcherSession.create(
40
+ user_id=1,
41
+ app_slug="focus-flow",
42
+ manifest_dict={...}, # parsed manifest.json
43
+ view="main",
44
+ entry_payload={"topic": "ECM"},
45
+ )
46
+ result = await session.call("storage", "set", {"key": "x", "value": 42})
47
+ events = session.drain_events() # list[dict] for SSE relay
48
+ ```
49
+
50
+ ## stdio bridge
51
+
52
+ ```bash
53
+ python -m anna_app_runtime_local.bridge
54
+ ```
55
+
56
+ Speaks JSON-RPC 2.0 over stdin/stdout (one envelope per line). Methods:
57
+
58
+ - `session.create` → `{ session_id, window_uuid, token, view, view_meta }`
59
+ - `session.call` → forwards to `dispatch()`; returns `{ ok, result | error }`
60
+ - `session.drain_events` → flushes queued SSE events
61
+ - `session.close` → drops the session
62
+ - `session.refresh_token` → re-mints a dev token for an active session
63
+ - `executas.register` → registers `{tool_id, project_dir, command?}` for
64
+ `tools.invoke`; first call lazy-spawns `uv run --project <dir> <tool_id>`
65
+ and reuses the warm subprocess.
66
+
67
+ The Node-side harness (`anna-app-cli/src/harness/bridge.ts`) speaks this
68
+ protocol over `python-shell`.
69
+
70
+ ## Local HMAC tokens
71
+
72
+ `mint_dev_token / verify_dev_token` use a per-user key at
73
+ `~/.anna-app/dev.key` (mode 600, generated on first use). TTL defaults
74
+ to 30 s — shorter than production's 120 s — to surface SDK refresh bugs
75
+ early.
76
+
77
+ These tokens are **not interoperable** with the production JWT path;
78
+ production's `_is_tool_allowed` already rejects `tool-dev-…` prefixes.
@@ -0,0 +1,10 @@
1
+ anna_app_runtime_local/__init__.py,sha256=o__2hxoCQOM5buEoqFrrfcecDAXDMCoXpBxpaqC-_X4,635
2
+ anna_app_runtime_local/bridge.py,sha256=qWAUhniNS0u1JdifxkqYChYbWGQLLOyPmAD6o8QKXmU,8281
3
+ anna_app_runtime_local/executa.py,sha256=GqGm5Bx4kDLXk0YzEUC-z4vU-HrawiA13uLsZiQS2mo,8045
4
+ anna_app_runtime_local/session.py,sha256=PHIOHBC3eODrdHE4caviEKfh9aowEVwqXLPkOjhU2Bk,3735
5
+ anna_app_runtime_local/store.py,sha256=wDP7HWND2T9P3v1_B0SrjslPMixXSYtF6zPKJb9di5M,6311
6
+ anna_app_runtime_local/token.py,sha256=EJXdifn7gr2JSTNPWw1Oiu0AOhib2XzM0Y-5FBemFMw,3455
7
+ anna_app_runtime_local-0.2.0a1.dist-info/METADATA,sha256=Oejp6jSIUbmtvBa6eqiA2Oqdu_gpTkfPTlrjVZlLIwg,2901
8
+ anna_app_runtime_local-0.2.0a1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ anna_app_runtime_local-0.2.0a1.dist-info/entry_points.txt,sha256=Sl0TKryZ1EpHarXAPrVkOAF0vFAYpieC8qC8NME9dy0,71
10
+ anna_app_runtime_local-0.2.0a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ anna-app-bridge = anna_app_runtime_local.bridge:main