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.
- anna_app_runtime_local/__init__.py +29 -0
- anna_app_runtime_local/bridge.py +236 -0
- anna_app_runtime_local/executa.py +223 -0
- anna_app_runtime_local/session.py +114 -0
- anna_app_runtime_local/store.py +178 -0
- anna_app_runtime_local/token.py +113 -0
- anna_app_runtime_local-0.2.0a1.dist-info/METADATA +78 -0
- anna_app_runtime_local-0.2.0a1.dist-info/RECORD +10 -0
- anna_app_runtime_local-0.2.0a1.dist-info/WHEEL +4 -0
- anna_app_runtime_local-0.2.0a1.dist-info/entry_points.txt +2 -0
|
@@ -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,,
|