anna-app-runtime-local 0.2.0a1__tar.gz

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,167 @@
1
+ # matrix agent repository
2
+ matrix/
3
+
4
+ # git diff files
5
+ git.diff
6
+
7
+ # Byte-compiled / optimized / DLL files
8
+ __pycache__/
9
+ *.py[cod]
10
+ *$py.class
11
+
12
+ # C extensions
13
+ *.so
14
+
15
+ # Distribution / packaging
16
+ .Python
17
+ build/
18
+ develop-eggs/
19
+ dist/
20
+ downloads/
21
+ eggs/
22
+ .eggs/
23
+ lib/
24
+ lib64/
25
+ parts/
26
+ sdist/
27
+ var/
28
+ wheels/
29
+ share/python-wheels/
30
+ *.egg-info/
31
+ .installed.cfg
32
+ *.egg
33
+ # setuptools sdist MANIFEST (only at repo root — must NOT swallow the
34
+ # packages/anna-app-schema/manifest/ schema bundle directory).
35
+ /MANIFEST
36
+
37
+ # PyInstaller
38
+ # Anchored to repo root for the same reason — `*.manifest` is too broad
39
+ # and was matching schema bundle files. Re-add specific paths if a real
40
+ # PyInstaller .manifest ever ships.
41
+ /*.manifest
42
+ *.spec
43
+
44
+ # Installer logs
45
+ pip-log.txt
46
+ pip-delete-this-directory.txt
47
+
48
+ # Unit test / coverage reports
49
+ htmlcov/
50
+ .tox/
51
+ .nox/
52
+ .coverage
53
+ .coverage.*
54
+ .cache
55
+ nosetests.xml
56
+ coverage.xml
57
+ *.cover
58
+ *.py,cover
59
+ .hypothesis/
60
+ .pytest_cache/
61
+ cover/
62
+
63
+ # Translations
64
+ *.mo
65
+ *.pot
66
+
67
+ # Django stuff:
68
+ *.log
69
+ local_settings.py
70
+ db.sqlite3
71
+ db.sqlite3-journal
72
+
73
+ # Flask stuff:
74
+ instance/
75
+ .webassets-cache
76
+
77
+ # Scrapy stuff:
78
+ .scrapy
79
+
80
+ # Sphinx documentation
81
+ docs/_build/
82
+
83
+ # PyBuilder
84
+ .pybuilder/
85
+ target/
86
+
87
+ # Jupyter Notebook
88
+ .ipynb_checkpoints
89
+
90
+ # IPython
91
+ profile_default/
92
+ ipython_config.py
93
+
94
+ # pyenv
95
+ .python-version
96
+
97
+ # pipenv
98
+ Pipfile.lock
99
+
100
+ # poetry
101
+ poetry.lock
102
+
103
+ # pdm
104
+ .pdm.toml
105
+
106
+ # PEP 582
107
+ __pypackages__/
108
+
109
+ # Celery stuff
110
+ celerybeat-schedule
111
+ celerybeat.pid
112
+
113
+ # SageMath parsed files
114
+ *.sage.py
115
+
116
+ # Environments
117
+ .env
118
+ .venv
119
+ env/
120
+ venv/
121
+ ENV/
122
+ env.bak/
123
+ venv.bak/
124
+
125
+ # Spyder project settings
126
+ .spyderproject
127
+ .spyproject
128
+
129
+ # Rope project settings
130
+ .ropeproject
131
+
132
+ # mkdocs documentation
133
+ /site
134
+
135
+ # mypy
136
+ .mypy_cache/
137
+ .dmypy.json
138
+ dmypy.json
139
+
140
+ # Pyre type checker
141
+ .pyre/
142
+
143
+ # pytype static type analyzer
144
+ .pytype/
145
+
146
+ # Cython debug symbols
147
+ cython_debug/
148
+
149
+ # IDE
150
+ .idea/
151
+ *.swp
152
+ *.swo
153
+ *~
154
+ .DS_Store
155
+
156
+ # Project specific
157
+ *.tmp
158
+ *.bak
159
+
160
+ # Security - RSA keys
161
+ private_key.pem
162
+ *.pem
163
+ !public_key.pem
164
+
165
+ scripts/nats_auth
166
+ # Anna App runtime cache
167
+ static/anna-apps-cache/
@@ -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,67 @@
1
+ # anna-app-runtime-local
2
+
3
+ Local-dev in-memory runtime for Anna Apps. Reuses the production
4
+ [`anna_app_rpc_dispatcher`](../../src/services/anna_app_rpc_dispatcher.py)
5
+ through `WindowStoreProtocol` so harness behaviour is byte-identical to
6
+ nexus production.
7
+
8
+ ## How it shares code with production
9
+
10
+ This package does not vendor a copy of the dispatcher. Instead it:
11
+
12
+ 1. Imports `dispatch`, `WindowStoreProtocol`, `HostRpcError` from
13
+ `src.services.anna_app_rpc_dispatcher` (matrix-nexus source).
14
+ 2. Provides `InMemoryWindowStore` — a `WindowStoreProtocol` impl that
15
+ keeps state in dictionaries and queues SSE events in memory.
16
+ 3. Wires the production `dispatch(store, …)` against in-memory state.
17
+
18
+ Because the wheel imports `src.services.…` directly, the bridge process
19
+ must be launched from a Python environment that has matrix-nexus on
20
+ `PYTHONPATH` (typically: `cd matrix-nexus && uv run python -m
21
+ anna_app_runtime_local.bridge`).
22
+
23
+ ## Public surface
24
+
25
+ ```python
26
+ from anna_app_runtime_local import LocalDispatcherSession, mint_dev_token
27
+
28
+ session = LocalDispatcherSession.create(
29
+ user_id=1,
30
+ app_slug="focus-flow",
31
+ manifest_dict={...}, # parsed manifest.json
32
+ view="main",
33
+ entry_payload={"topic": "ECM"},
34
+ )
35
+ result = await session.call("storage", "set", {"key": "x", "value": 42})
36
+ events = session.drain_events() # list[dict] for SSE relay
37
+ ```
38
+
39
+ ## stdio bridge
40
+
41
+ ```bash
42
+ python -m anna_app_runtime_local.bridge
43
+ ```
44
+
45
+ Speaks JSON-RPC 2.0 over stdin/stdout (one envelope per line). Methods:
46
+
47
+ - `session.create` → `{ session_id, window_uuid, token, view, view_meta }`
48
+ - `session.call` → forwards to `dispatch()`; returns `{ ok, result | error }`
49
+ - `session.drain_events` → flushes queued SSE events
50
+ - `session.close` → drops the session
51
+ - `session.refresh_token` → re-mints a dev token for an active session
52
+ - `executas.register` → registers `{tool_id, project_dir, command?}` for
53
+ `tools.invoke`; first call lazy-spawns `uv run --project <dir> <tool_id>`
54
+ and reuses the warm subprocess.
55
+
56
+ The Node-side harness (`anna-app-cli/src/harness/bridge.ts`) speaks this
57
+ protocol over `python-shell`.
58
+
59
+ ## Local HMAC tokens
60
+
61
+ `mint_dev_token / verify_dev_token` use a per-user key at
62
+ `~/.anna-app/dev.key` (mode 600, generated on first use). TTL defaults
63
+ to 30 s — shorter than production's 120 s — to surface SDK refresh bugs
64
+ early.
65
+
66
+ These tokens are **not interoperable** with the production JWT path;
67
+ production's `_is_tool_allowed` already rejects `tool-dev-…` prefixes.
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "anna-app-runtime-local"
3
+ version = "0.2.0a1"
4
+ description = "Local-dev in-memory runtime for Anna Apps. Wraps anna-app-core's dispatcher with an InMemoryWindowStore + WebSocket bridge."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "Proprietary" }
8
+ authors = [{ name = "Talent AI" }]
9
+ keywords = ["anna", "anna-app", "executa", "dev", "harness"]
10
+
11
+ # v0.2 — truly standalone. Runtime deps:
12
+ # * `anna-app-core` provides the dispatcher + manifest schema + protocol.
13
+ # * Everything else (bridge, store, token mint) is pure stdlib.
14
+ dependencies = [
15
+ "anna-app-core>=0.2.0a1,<0.3",
16
+ ]
17
+
18
+ [project.scripts]
19
+ anna-app-bridge = "anna_app_runtime_local.bridge:main"
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/anna_app_runtime_local"]
@@ -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()