anna-app-core 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_core/__init__.py +58 -0
- anna_app_core/acl.py +66 -0
- anna_app_core/dispatcher.py +505 -0
- anna_app_core/errors.py +33 -0
- anna_app_core/manifest.py +163 -0
- anna_app_core/protocols.py +50 -0
- anna_app_core/runtime.py +73 -0
- anna_app_core/versions.py +9 -0
- anna_app_core-0.2.0a1.dist-info/METADATA +38 -0
- anna_app_core-0.2.0a1.dist-info/RECORD +11 -0
- anna_app_core-0.2.0a1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""anna-app-core: stable contract surface shared by matrix-nexus and
|
|
2
|
+
anna-app-runtime-local.
|
|
3
|
+
|
|
4
|
+
v0.2.0a1 — full extraction. The complete RPC dispatcher, manifest pydantic
|
|
5
|
+
schemas, and helper functions now live here; matrix-nexus re-exports them
|
|
6
|
+
for backward compatibility (see README.md).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .acl import host_api_allows
|
|
10
|
+
from .dispatcher import dispatch
|
|
11
|
+
from .errors import HostRpcError, WindowError, WindowPermissionError
|
|
12
|
+
from .manifest import (
|
|
13
|
+
AppDevConfig,
|
|
14
|
+
AppManifest,
|
|
15
|
+
ManifestExecutaRef,
|
|
16
|
+
UiBundleSection,
|
|
17
|
+
UiHostApiSpec,
|
|
18
|
+
UiManifestSection,
|
|
19
|
+
UiSize,
|
|
20
|
+
UiViewSpec,
|
|
21
|
+
WindowViewMeta,
|
|
22
|
+
)
|
|
23
|
+
from .protocols import WindowStoreProtocol
|
|
24
|
+
from .runtime import scopes_from_manifest, select_view, view_meta
|
|
25
|
+
from .versions import DISPATCHER_VERSION, SDK_VERSION
|
|
26
|
+
|
|
27
|
+
__version__ = "0.2.0a1"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
# errors
|
|
31
|
+
"HostRpcError",
|
|
32
|
+
"WindowError",
|
|
33
|
+
"WindowPermissionError",
|
|
34
|
+
# protocols
|
|
35
|
+
"WindowStoreProtocol",
|
|
36
|
+
# acl
|
|
37
|
+
"host_api_allows",
|
|
38
|
+
# dispatcher
|
|
39
|
+
"dispatch",
|
|
40
|
+
# manifest schemas
|
|
41
|
+
"AppManifest",
|
|
42
|
+
"AppDevConfig",
|
|
43
|
+
"ManifestExecutaRef",
|
|
44
|
+
"UiBundleSection",
|
|
45
|
+
"UiHostApiSpec",
|
|
46
|
+
"UiManifestSection",
|
|
47
|
+
"UiSize",
|
|
48
|
+
"UiViewSpec",
|
|
49
|
+
"WindowViewMeta",
|
|
50
|
+
# runtime helpers
|
|
51
|
+
"select_view",
|
|
52
|
+
"scopes_from_manifest",
|
|
53
|
+
"view_meta",
|
|
54
|
+
# versions
|
|
55
|
+
"DISPATCHER_VERSION",
|
|
56
|
+
"SDK_VERSION",
|
|
57
|
+
"__version__",
|
|
58
|
+
]
|
anna_app_core/acl.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Host-API ACL check.
|
|
2
|
+
|
|
3
|
+
Accepts either a typed ``AppManifest`` (preferred — production + harness)
|
|
4
|
+
or a plain ``dict`` (e.g. JSON-loaded manifest before pydantic validation).
|
|
5
|
+
|
|
6
|
+
Mirrors the pre-extraction implementation in
|
|
7
|
+
``matrix-nexus/src/services/anna_app_runtime_service.py::host_api_allows``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Union
|
|
13
|
+
|
|
14
|
+
from .manifest import AppManifest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _ui_host_api(
|
|
18
|
+
manifest: Union[AppManifest, dict[str, Any]],
|
|
19
|
+
) -> dict[str, list[str]] | None:
|
|
20
|
+
"""Return the ``ui.host_api`` mapping (ns -> list[method]) or ``None``
|
|
21
|
+
if the manifest has no UI section."""
|
|
22
|
+
if isinstance(manifest, AppManifest):
|
|
23
|
+
if manifest.ui is None:
|
|
24
|
+
return None
|
|
25
|
+
api = manifest.ui.host_api
|
|
26
|
+
return {
|
|
27
|
+
"tools": api.tools,
|
|
28
|
+
"chat": api.chat,
|
|
29
|
+
"artifact": api.artifact,
|
|
30
|
+
"llm": api.llm,
|
|
31
|
+
"fs": api.fs,
|
|
32
|
+
"storage": api.storage,
|
|
33
|
+
"prefs": api.prefs,
|
|
34
|
+
}
|
|
35
|
+
ui = manifest.get("ui") if isinstance(manifest, dict) else None
|
|
36
|
+
if ui is None:
|
|
37
|
+
return None
|
|
38
|
+
return ui.get("host_api") or {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def host_api_allows(
|
|
42
|
+
manifest: Union[AppManifest, dict[str, Any]],
|
|
43
|
+
ns: str,
|
|
44
|
+
method: str,
|
|
45
|
+
) -> bool:
|
|
46
|
+
"""Return ``True`` iff ``ns.method`` is granted by the manifest's ACL.
|
|
47
|
+
|
|
48
|
+
`window.*` is always granted. ``tools.invoke`` and ``tools.list`` are
|
|
49
|
+
granted whenever the ``tools`` set is non-empty (per-tool gating happens
|
|
50
|
+
inside the handler).
|
|
51
|
+
"""
|
|
52
|
+
if ns == "window":
|
|
53
|
+
return True
|
|
54
|
+
api_map = _ui_host_api(manifest)
|
|
55
|
+
if api_map is None:
|
|
56
|
+
return False
|
|
57
|
+
methods = api_map.get(ns)
|
|
58
|
+
if not methods:
|
|
59
|
+
return False
|
|
60
|
+
if "*" in methods:
|
|
61
|
+
return True
|
|
62
|
+
if method in methods:
|
|
63
|
+
return True
|
|
64
|
+
if ns == "tools" and method in ("invoke", "list"):
|
|
65
|
+
return True
|
|
66
|
+
return False
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""Anna App UI Runtime — RPC dispatcher (host-side, runtime-agnostic).
|
|
2
|
+
|
|
3
|
+
Single source of truth for every host_api handler from design §8.2. Run with
|
|
4
|
+
either:
|
|
5
|
+
|
|
6
|
+
* `SqlAlchemyWindowStore(db)` (production, in matrix-nexus) — auto-wrapped
|
|
7
|
+
by nexus's compatibility shim ``src/services/anna_app_rpc_dispatcher.py``.
|
|
8
|
+
* `InMemoryWindowStore()` (local-dev harness, ``anna-app-runtime-local``).
|
|
9
|
+
|
|
10
|
+
Both satisfy `WindowStoreProtocol`; the same handler bodies (ACL gating,
|
|
11
|
+
tool-id splitting, event payload shapes) execute in either environment.
|
|
12
|
+
|
|
13
|
+
Namespaces (design §8.2):
|
|
14
|
+
|
|
15
|
+
================ ============== =================== ==========================
|
|
16
|
+
namespace method scope status
|
|
17
|
+
================ ============== =================== ==========================
|
|
18
|
+
window hello (always) ✅
|
|
19
|
+
window ready (always) ✅
|
|
20
|
+
window set_title window ✅
|
|
21
|
+
window resize window ✅
|
|
22
|
+
window focus window ✅
|
|
23
|
+
window close window ✅
|
|
24
|
+
window open_view window ✅
|
|
25
|
+
window report_error (always) ✅
|
|
26
|
+
tools invoke tools.invoke ✅ (Executa via NATS)
|
|
27
|
+
tools list tools ✅
|
|
28
|
+
chat read_history chat.read ⏳ stub (Phase 3)
|
|
29
|
+
chat write_message chat.write_message ⏳ stub (Phase 3)
|
|
30
|
+
chat append_artifact chat.append_artifact ✅
|
|
31
|
+
artifact create/update/delete ⏳ stub
|
|
32
|
+
llm complete llm.complete ⏳ stub (Phase 3)
|
|
33
|
+
fs read/write fs.* ⏳ stub (Phase 3)
|
|
34
|
+
storage get/set/delete storage.* ✅ (runtime_state)
|
|
35
|
+
prefs get prefs.read ⏳ stub
|
|
36
|
+
================ ============== =================== ==========================
|
|
37
|
+
|
|
38
|
+
When changing public behaviour here, bump ``DISPATCHER_VERSION`` in
|
|
39
|
+
``versions.py`` and update ``packages/VERSIONS.md``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import logging
|
|
45
|
+
from datetime import datetime
|
|
46
|
+
from typing import Any, Awaitable, Callable
|
|
47
|
+
|
|
48
|
+
from .acl import host_api_allows
|
|
49
|
+
from .errors import HostRpcError, WindowError, WindowPermissionError
|
|
50
|
+
from .manifest import AppManifest
|
|
51
|
+
from .protocols import WindowStoreProtocol
|
|
52
|
+
from .runtime import _scopes_from_manifest, _select_view, _view_meta
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# -----------------------------------------------------------------------------
|
|
58
|
+
# window.* handlers
|
|
59
|
+
# -----------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _h_window_hello(
|
|
63
|
+
store: WindowStoreProtocol,
|
|
64
|
+
*,
|
|
65
|
+
window: Any,
|
|
66
|
+
manifest: AppManifest,
|
|
67
|
+
args: dict,
|
|
68
|
+
) -> dict:
|
|
69
|
+
"""Initial handshake — returns capabilities + view_meta + entry/runtime."""
|
|
70
|
+
view = _select_view(manifest, window.view)
|
|
71
|
+
return {
|
|
72
|
+
"capabilities": {
|
|
73
|
+
"host_api_version": "0.1.0",
|
|
74
|
+
"scopes": _scopes_from_manifest(manifest),
|
|
75
|
+
},
|
|
76
|
+
"view_meta": _view_meta(view).model_dump(),
|
|
77
|
+
"entry_payload": window.entry_payload or {},
|
|
78
|
+
"runtime_state": window.runtime_state or {},
|
|
79
|
+
"geometry": window.geometry or {},
|
|
80
|
+
"is_resume": bool(window.runtime_state),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _h_window_ready(store, *, window, manifest, args) -> dict:
|
|
85
|
+
return {"ok": True}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def _h_window_set_title(store, *, window, manifest, args) -> dict:
|
|
89
|
+
title = str(args.get("title", ""))[:200]
|
|
90
|
+
window.title = title
|
|
91
|
+
await store.commit()
|
|
92
|
+
await store.emit_event(
|
|
93
|
+
window.user_id,
|
|
94
|
+
"title_changed",
|
|
95
|
+
{"window_uuid": window.window_uuid, "title": title},
|
|
96
|
+
)
|
|
97
|
+
return {"title": title}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def _h_window_resize(store, *, window, manifest, args) -> dict:
|
|
101
|
+
try:
|
|
102
|
+
w = int(args.get("w"))
|
|
103
|
+
h = int(args.get("h"))
|
|
104
|
+
except (TypeError, ValueError):
|
|
105
|
+
raise HostRpcError("invalid_arg", "w/h must be integers")
|
|
106
|
+
geo = dict(window.geometry or {})
|
|
107
|
+
geo.update({"w": w, "h": h})
|
|
108
|
+
window.geometry = geo
|
|
109
|
+
await store.commit()
|
|
110
|
+
await store.emit_event(
|
|
111
|
+
window.user_id,
|
|
112
|
+
"geometry_changed",
|
|
113
|
+
{"window_uuid": window.window_uuid, "geometry": geo},
|
|
114
|
+
)
|
|
115
|
+
return {"w": w, "h": h}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def _h_window_focus(store, *, window, manifest, args) -> dict:
|
|
119
|
+
window.last_focus_at = datetime.utcnow()
|
|
120
|
+
await store.commit()
|
|
121
|
+
await store.emit_event(
|
|
122
|
+
window.user_id,
|
|
123
|
+
"window_focus_changed",
|
|
124
|
+
{
|
|
125
|
+
"window_uuid": window.window_uuid,
|
|
126
|
+
"last_focus_at": window.last_focus_at.isoformat(),
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
return {"focused": True}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def _h_window_close(store, *, window, manifest, args) -> dict:
|
|
133
|
+
reason = str(args.get("reason") or "iframe_close")[:80]
|
|
134
|
+
window.status = "closed"
|
|
135
|
+
window.closed_at = datetime.utcnow()
|
|
136
|
+
window.closed_reason = reason
|
|
137
|
+
await store.commit()
|
|
138
|
+
await store.emit_event(
|
|
139
|
+
window.user_id,
|
|
140
|
+
"close_view",
|
|
141
|
+
{"window_uuid": window.window_uuid, "reason": reason},
|
|
142
|
+
)
|
|
143
|
+
return {"closed": True}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def _h_window_open_view(store, *, window, manifest, args) -> dict:
|
|
147
|
+
"""Allow the iframe to summon a sibling view of the same app."""
|
|
148
|
+
view_name = args.get("view")
|
|
149
|
+
payload = args.get("payload") or {}
|
|
150
|
+
try:
|
|
151
|
+
detail, opened_new = await store.open_sibling_window(
|
|
152
|
+
window=window, view_name=view_name, payload=payload
|
|
153
|
+
)
|
|
154
|
+
except NotImplementedError:
|
|
155
|
+
raise HostRpcError(
|
|
156
|
+
"not_implemented", "window.open_view is not available in this runtime"
|
|
157
|
+
)
|
|
158
|
+
except WindowError as e:
|
|
159
|
+
raise HostRpcError("invalid_view", str(e))
|
|
160
|
+
await store.emit_event(
|
|
161
|
+
window.user_id,
|
|
162
|
+
"open_view",
|
|
163
|
+
{
|
|
164
|
+
"window_uuid": detail.window_uuid,
|
|
165
|
+
"app": detail.app,
|
|
166
|
+
"view": detail.view,
|
|
167
|
+
"view_meta": detail.view_meta.model_dump(),
|
|
168
|
+
"bundle_url": detail.bundle_url,
|
|
169
|
+
"sdk_url": detail.sdk_url,
|
|
170
|
+
"token": detail.token,
|
|
171
|
+
"entry_payload": detail.entry_payload,
|
|
172
|
+
"conversation_session_uuid": detail.conversation_session_uuid,
|
|
173
|
+
"source": "iframe_open_view",
|
|
174
|
+
"opened_new": opened_new,
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
return {"window_uuid": detail.window_uuid, "opened_new": opened_new}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def _h_window_report_error(store, *, window, manifest, args) -> dict:
|
|
181
|
+
err = args.get("error") or {}
|
|
182
|
+
logger.warning(
|
|
183
|
+
"anna_app iframe error wid=%s: %s",
|
|
184
|
+
window.window_uuid,
|
|
185
|
+
str(err)[:500],
|
|
186
|
+
)
|
|
187
|
+
return {"acknowledged": True}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# -----------------------------------------------------------------------------
|
|
191
|
+
# tools.* handlers
|
|
192
|
+
# -----------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _split_tool_id(
|
|
196
|
+
tool_id: str, *, explicit_method: str | None = None
|
|
197
|
+
) -> tuple[str, str]:
|
|
198
|
+
"""Resolve ``(plugin_name, tool_name)`` for NATS routing.
|
|
199
|
+
|
|
200
|
+
Mint-only tool_ids (``tool-{handle}-{slug}-{uniq}``) contain no
|
|
201
|
+
separator, so the bundle now passes the target method explicitly via
|
|
202
|
+
``args.method``. The legacy ``plugin.tool`` / ``plugin__tool`` form
|
|
203
|
+
is still accepted for back-compat.
|
|
204
|
+
"""
|
|
205
|
+
if explicit_method:
|
|
206
|
+
method = explicit_method.strip()
|
|
207
|
+
if not method:
|
|
208
|
+
raise HostRpcError("invalid_arg", "method must be a non-empty string")
|
|
209
|
+
return tool_id, method
|
|
210
|
+
for sep in ("__", "."):
|
|
211
|
+
if sep in tool_id:
|
|
212
|
+
head, tail = tool_id.split(sep, 1)
|
|
213
|
+
return head, tail
|
|
214
|
+
raise HostRpcError(
|
|
215
|
+
"invalid_arg",
|
|
216
|
+
f"tool_id '{tool_id}' has no plugin.tool separator and no "
|
|
217
|
+
"`method` arg was provided",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _is_tool_allowed(manifest: AppManifest, tool_id: str) -> bool:
|
|
222
|
+
if manifest.ui is None:
|
|
223
|
+
return False
|
|
224
|
+
refs = manifest.ui.host_api.tools
|
|
225
|
+
required_ids = {r.tool_id for r in manifest.required_executas}
|
|
226
|
+
optional_ids = {r.tool_id for r in manifest.optional_executas}
|
|
227
|
+
for ref in refs:
|
|
228
|
+
if ref == "required:*" and tool_id in required_ids:
|
|
229
|
+
return True
|
|
230
|
+
if ref == "optional:*" and tool_id in optional_ids:
|
|
231
|
+
return True
|
|
232
|
+
if ref == tool_id:
|
|
233
|
+
return True
|
|
234
|
+
if ref.endswith(f":{tool_id}"):
|
|
235
|
+
return True
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def _h_tools_list(store, *, window, manifest, args) -> dict:
|
|
240
|
+
"""List the executa tool_ids that this window is allowed to invoke."""
|
|
241
|
+
allowed: list[str] = []
|
|
242
|
+
required_ids = [r.tool_id for r in manifest.required_executas]
|
|
243
|
+
optional_ids = [r.tool_id for r in manifest.optional_executas]
|
|
244
|
+
for tid in required_ids + optional_ids:
|
|
245
|
+
if _is_tool_allowed(manifest, tid):
|
|
246
|
+
allowed.append(tid)
|
|
247
|
+
return {"tools": [{"tool_id": tid} for tid in allowed]}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def _h_tools_invoke(store, *, window, manifest, args) -> dict:
|
|
251
|
+
tool_id = args.get("tool_id")
|
|
252
|
+
tool_args = args.get("args") or {}
|
|
253
|
+
method = args.get("method")
|
|
254
|
+
if not isinstance(tool_id, str) or not tool_id:
|
|
255
|
+
raise HostRpcError("invalid_arg", "tool_id is required")
|
|
256
|
+
if method is not None and not isinstance(method, str):
|
|
257
|
+
raise HostRpcError("invalid_arg", "method must be a string when provided")
|
|
258
|
+
if not _is_tool_allowed(manifest, tool_id):
|
|
259
|
+
raise HostRpcError(
|
|
260
|
+
"permission_denied",
|
|
261
|
+
f"tool '{tool_id}' not whitelisted by host_api.tools",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
plugin_name, tool_name = _split_tool_id(tool_id, explicit_method=method)
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
result = await store.invoke_executa_tool(
|
|
268
|
+
user_id=window.user_id,
|
|
269
|
+
plugin_name=plugin_name,
|
|
270
|
+
tool_name=tool_name,
|
|
271
|
+
tool_args=tool_args,
|
|
272
|
+
)
|
|
273
|
+
except NotImplementedError:
|
|
274
|
+
raise HostRpcError(
|
|
275
|
+
"not_implemented", "tools.invoke is not available in this runtime"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
logger.info(
|
|
279
|
+
"anna-app tools.invoke raw result for %s.%s: %r",
|
|
280
|
+
plugin_name,
|
|
281
|
+
tool_name,
|
|
282
|
+
result,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Unwrap two layers of envelope so the iframe sees the plugin's payload
|
|
286
|
+
# directly. Either layer reporting failure is surfaced as a HostRpcError
|
|
287
|
+
# so the SDK rejects the promise instead of silently returning a wrapper.
|
|
288
|
+
def _summary(d: Any) -> str:
|
|
289
|
+
if isinstance(d, dict):
|
|
290
|
+
return f"keys={sorted(d.keys())!r}"
|
|
291
|
+
return f"type={type(d).__name__}"
|
|
292
|
+
|
|
293
|
+
if not isinstance(result, dict) or not result.get("success", False):
|
|
294
|
+
raise HostRpcError(
|
|
295
|
+
"executa_unavailable",
|
|
296
|
+
(
|
|
297
|
+
str(result.get("error") or f"executa RPC failed ({_summary(result)})")
|
|
298
|
+
if isinstance(result, dict)
|
|
299
|
+
else f"executa RPC failed ({_summary(result)})"
|
|
300
|
+
),
|
|
301
|
+
)
|
|
302
|
+
invoke = result.get("data") or {}
|
|
303
|
+
if not isinstance(invoke, dict) or not invoke.get("success", False):
|
|
304
|
+
raise HostRpcError(
|
|
305
|
+
"tool_failed",
|
|
306
|
+
(
|
|
307
|
+
str(
|
|
308
|
+
invoke.get("error") or f"tool returned failure ({_summary(invoke)})"
|
|
309
|
+
)
|
|
310
|
+
if isinstance(invoke, dict)
|
|
311
|
+
else f"tool returned failure ({_summary(invoke)})"
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
payload = invoke.get("data")
|
|
315
|
+
return payload if isinstance(payload, dict) else {}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# -----------------------------------------------------------------------------
|
|
319
|
+
# chat.* handlers
|
|
320
|
+
# -----------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def _h_chat_append_artifact(store, *, window, manifest, args) -> dict:
|
|
324
|
+
"""Append an ``app_event`` artifact to the associated conversation."""
|
|
325
|
+
artifact = args.get("artifact") or {}
|
|
326
|
+
if not isinstance(artifact, dict):
|
|
327
|
+
raise HostRpcError("invalid_arg", "artifact must be an object")
|
|
328
|
+
artifact = {
|
|
329
|
+
"kind": str(artifact.get("kind", "app_event"))[:40],
|
|
330
|
+
"app_slug": artifact.get("app_slug"),
|
|
331
|
+
"summary": str(artifact.get("summary", ""))[:1000],
|
|
332
|
+
"payload_ref": artifact.get("payload_ref"),
|
|
333
|
+
"data": artifact.get("data"),
|
|
334
|
+
"ts": datetime.utcnow().isoformat(),
|
|
335
|
+
"window_uuid": window.window_uuid,
|
|
336
|
+
}
|
|
337
|
+
await store.emit_event(
|
|
338
|
+
window.user_id,
|
|
339
|
+
"artifact_appended",
|
|
340
|
+
{"window_uuid": window.window_uuid, "artifact": artifact},
|
|
341
|
+
)
|
|
342
|
+
artifact_id = f"app-{window.window_uuid[:8]}-{int(datetime.utcnow().timestamp())}"
|
|
343
|
+
return {"artifact_id": artifact_id}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
async def _h_chat_write_message(store, *, window, manifest, args) -> dict:
|
|
347
|
+
"""Stub: emit a synthetic event to chat (full thread persistence Phase 3)."""
|
|
348
|
+
role = args.get("role", "assistant")
|
|
349
|
+
content = str(args.get("content", ""))[:4000]
|
|
350
|
+
await store.emit_event(
|
|
351
|
+
window.user_id,
|
|
352
|
+
"chat_message_from_app",
|
|
353
|
+
{
|
|
354
|
+
"window_uuid": window.window_uuid,
|
|
355
|
+
"role": role,
|
|
356
|
+
"content": content,
|
|
357
|
+
},
|
|
358
|
+
)
|
|
359
|
+
return {"message_id": f"msg-{int(datetime.utcnow().timestamp() * 1000)}"}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
async def _h_chat_read_history(store, *, window, manifest, args) -> dict:
|
|
363
|
+
raise HostRpcError("not_implemented", "chat.read_history is Phase 3")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# -----------------------------------------------------------------------------
|
|
367
|
+
# storage.* handlers (operate on window.runtime_state)
|
|
368
|
+
# -----------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
async def _h_storage_get(store, *, window, manifest, args) -> dict:
|
|
372
|
+
key = str(args.get("key", ""))
|
|
373
|
+
if not key:
|
|
374
|
+
raise HostRpcError("invalid_arg", "key required")
|
|
375
|
+
return {"value": (window.runtime_state or {}).get(key)}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
async def _h_storage_set(store, *, window, manifest, args) -> dict:
|
|
379
|
+
key = str(args.get("key", ""))
|
|
380
|
+
if not key:
|
|
381
|
+
raise HostRpcError("invalid_arg", "key required")
|
|
382
|
+
value = args.get("value")
|
|
383
|
+
state = dict(window.runtime_state or {})
|
|
384
|
+
state[key] = value
|
|
385
|
+
user = await store.get_user(window.user_id)
|
|
386
|
+
try:
|
|
387
|
+
await store.replace_runtime_state(
|
|
388
|
+
user=user, window_uuid=window.window_uuid, state=state
|
|
389
|
+
)
|
|
390
|
+
except WindowError as e:
|
|
391
|
+
raise HostRpcError("too_large", str(e))
|
|
392
|
+
await store.emit_event(
|
|
393
|
+
window.user_id,
|
|
394
|
+
"runtime_state_synced",
|
|
395
|
+
{"window_uuid": window.window_uuid, "patch": {key: value}},
|
|
396
|
+
)
|
|
397
|
+
return {"ok": True}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
async def _h_storage_delete(store, *, window, manifest, args) -> dict:
|
|
401
|
+
key = str(args.get("key", ""))
|
|
402
|
+
if not key:
|
|
403
|
+
raise HostRpcError("invalid_arg", "key required")
|
|
404
|
+
state = dict(window.runtime_state or {})
|
|
405
|
+
state.pop(key, None)
|
|
406
|
+
user = await store.get_user(window.user_id)
|
|
407
|
+
await store.replace_runtime_state(
|
|
408
|
+
user=user, window_uuid=window.window_uuid, state=state
|
|
409
|
+
)
|
|
410
|
+
await store.emit_event(
|
|
411
|
+
window.user_id,
|
|
412
|
+
"runtime_state_synced",
|
|
413
|
+
{"window_uuid": window.window_uuid, "patch": {key: None}},
|
|
414
|
+
)
|
|
415
|
+
return {"ok": True}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# -----------------------------------------------------------------------------
|
|
419
|
+
# Stub handlers (Phase 3 surface)
|
|
420
|
+
# -----------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def _h_not_implemented(store, *, window, manifest, args) -> dict:
|
|
424
|
+
raise HostRpcError("not_implemented", "endpoint not yet available")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# -----------------------------------------------------------------------------
|
|
428
|
+
# Dispatch table
|
|
429
|
+
# -----------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
Handler = Callable[..., Awaitable[dict]]
|
|
432
|
+
|
|
433
|
+
_DISPATCH: dict[tuple[str, str], Handler] = {
|
|
434
|
+
# window
|
|
435
|
+
("window", "hello"): _h_window_hello,
|
|
436
|
+
("window", "ready"): _h_window_ready,
|
|
437
|
+
("window", "set_title"): _h_window_set_title,
|
|
438
|
+
("window", "resize"): _h_window_resize,
|
|
439
|
+
("window", "focus"): _h_window_focus,
|
|
440
|
+
("window", "close"): _h_window_close,
|
|
441
|
+
("window", "open_view"): _h_window_open_view,
|
|
442
|
+
("window", "report_error"): _h_window_report_error,
|
|
443
|
+
# tools
|
|
444
|
+
("tools", "list"): _h_tools_list,
|
|
445
|
+
("tools", "invoke"): _h_tools_invoke,
|
|
446
|
+
# chat
|
|
447
|
+
("chat", "append_artifact"): _h_chat_append_artifact,
|
|
448
|
+
("chat", "write_message"): _h_chat_write_message,
|
|
449
|
+
("chat", "read_history"): _h_chat_read_history,
|
|
450
|
+
# storage
|
|
451
|
+
("storage", "get"): _h_storage_get,
|
|
452
|
+
("storage", "set"): _h_storage_set,
|
|
453
|
+
("storage", "delete"): _h_storage_delete,
|
|
454
|
+
# phase 3 stubs
|
|
455
|
+
("artifact", "create"): _h_not_implemented,
|
|
456
|
+
("artifact", "update"): _h_not_implemented,
|
|
457
|
+
("artifact", "delete"): _h_not_implemented,
|
|
458
|
+
("llm", "complete"): _h_not_implemented,
|
|
459
|
+
("fs", "read"): _h_not_implemented,
|
|
460
|
+
("fs", "write"): _h_not_implemented,
|
|
461
|
+
("prefs", "get"): _h_not_implemented,
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
_NO_AUTH_NEEDED = {("window", "hello"), ("window", "ready"), ("window", "report_error")}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
async def dispatch(
|
|
469
|
+
store: WindowStoreProtocol,
|
|
470
|
+
*,
|
|
471
|
+
window: Any,
|
|
472
|
+
manifest: AppManifest,
|
|
473
|
+
ns: str,
|
|
474
|
+
method: str,
|
|
475
|
+
args: dict,
|
|
476
|
+
) -> dict:
|
|
477
|
+
"""Route an envelope to the right handler.
|
|
478
|
+
|
|
479
|
+
Caller has already verified the window token and loaded ``window`` +
|
|
480
|
+
``manifest``. ``store`` is any object satisfying `WindowStoreProtocol`.
|
|
481
|
+
|
|
482
|
+
Production callers (matrix-nexus) wrap their `AsyncSession` as
|
|
483
|
+
`SqlAlchemyWindowStore` before calling here. The local-dev harness
|
|
484
|
+
passes its `InMemoryWindowStore` directly.
|
|
485
|
+
"""
|
|
486
|
+
handler = _DISPATCH.get((ns, method))
|
|
487
|
+
if handler is None:
|
|
488
|
+
raise HostRpcError("unknown_method", f"{ns}.{method} is not defined")
|
|
489
|
+
if (ns, method) not in _NO_AUTH_NEEDED:
|
|
490
|
+
if not host_api_allows(manifest, ns, method):
|
|
491
|
+
raise HostRpcError(
|
|
492
|
+
"permission_denied",
|
|
493
|
+
f"manifest does not grant '{ns}.{method}'",
|
|
494
|
+
)
|
|
495
|
+
try:
|
|
496
|
+
return await handler(store, window=window, manifest=manifest, args=args or {})
|
|
497
|
+
except HostRpcError:
|
|
498
|
+
raise
|
|
499
|
+
except WindowPermissionError as e:
|
|
500
|
+
raise HostRpcError("permission_denied", str(e))
|
|
501
|
+
except WindowError as e:
|
|
502
|
+
raise HostRpcError("invalid_arg", str(e))
|
|
503
|
+
except Exception as e: # pragma: no cover
|
|
504
|
+
logger.exception("rpc handler crashed")
|
|
505
|
+
raise HostRpcError("internal", f"handler crashed: {e}")
|
anna_app_core/errors.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Error types crossing the host<->app RPC boundary.
|
|
2
|
+
|
|
3
|
+
These mirror `matrix-nexus/src/services/anna_app_runtime_service.py` and
|
|
4
|
+
`matrix-nexus/src/services/anna_app_rpc_dispatcher.py`. The nexus copies
|
|
5
|
+
will be replaced with re-export shims in the Phase 7 follow-up; for now
|
|
6
|
+
the definitions are duplicated to keep `anna-app-core` truly standalone.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WindowError(ValueError):
|
|
15
|
+
"""Application-level failure (mapped to HTTP 4xx in the nexus router)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WindowPermissionError(WindowError):
|
|
19
|
+
"""Raised when a `host_api` ACL check fails."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HostRpcError(Exception):
|
|
23
|
+
"""Structured error returned by RPC handlers.
|
|
24
|
+
|
|
25
|
+
Carries a stable `code` (e.g. ``not_implemented``, ``permission_denied``)
|
|
26
|
+
so frontends can branch deterministically without scraping messages.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, code: str, message: str, *, details: dict[str, Any] | None = None):
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.code = code
|
|
32
|
+
self.message = message
|
|
33
|
+
self.details = details
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Pydantic schemas for the Anna App manifest + window view metadata.
|
|
2
|
+
|
|
3
|
+
Source-of-truth lives here (anna-app-core). matrix-nexus re-exports these
|
|
4
|
+
from `src/schemas/anna_app.py` so existing nexus imports keep working.
|
|
5
|
+
|
|
6
|
+
NOTE: nexus also has many *non*-manifest schemas (`AnnaApp`, `AnnaAppVersion`,
|
|
7
|
+
`UserExecuta`, …) that are intentionally kept in nexus — they touch the DB
|
|
8
|
+
and have nothing to do with the local-dev contract.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# -----------------------------------------------------------------------------
|
|
19
|
+
# Manifest
|
|
20
|
+
# -----------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ManifestExecutaRef(BaseModel):
|
|
24
|
+
tool_id: str = Field(..., min_length=1, max_length=200)
|
|
25
|
+
min_version: Optional[str] = Field(None, max_length=40)
|
|
26
|
+
version: Optional[str] = Field(
|
|
27
|
+
None,
|
|
28
|
+
max_length=40,
|
|
29
|
+
description=(
|
|
30
|
+
"Executa snapshot version to pin. Omit or use 'latest' to "
|
|
31
|
+
"auto-freeze the current Executa state at publish time."
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# -----------------------------------------------------------------------------
|
|
37
|
+
# UI manifest section (Anna App UI Runtime §5)
|
|
38
|
+
# -----------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class UiSize(BaseModel):
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
w: int = Field(..., ge=120, le=4096)
|
|
45
|
+
h: int = Field(..., ge=120, le=4096)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UiBundleSection(BaseModel):
|
|
49
|
+
model_config = ConfigDict(extra="forbid")
|
|
50
|
+
|
|
51
|
+
format: str = Field("static-spa", max_length=20)
|
|
52
|
+
entry: str = Field(..., min_length=1, max_length=200)
|
|
53
|
+
external_origins: list[str] = Field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class UiViewSpec(BaseModel):
|
|
57
|
+
model_config = ConfigDict(extra="forbid")
|
|
58
|
+
|
|
59
|
+
name: str = Field(..., min_length=1, max_length=40)
|
|
60
|
+
title: str = Field(..., min_length=1, max_length=120)
|
|
61
|
+
default: bool = Field(False)
|
|
62
|
+
entry: Optional[str] = Field(None, max_length=300)
|
|
63
|
+
min_size: Optional[UiSize] = None
|
|
64
|
+
default_size: Optional[UiSize] = None
|
|
65
|
+
max_size: Optional[UiSize] = None
|
|
66
|
+
resizable: bool = Field(True)
|
|
67
|
+
movable: bool = Field(True)
|
|
68
|
+
single_instance: bool = Field(False)
|
|
69
|
+
summary_template: Optional[str] = Field(None, max_length=400)
|
|
70
|
+
icon: Optional[str] = Field(None, max_length=200)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class UiHostApiSpec(BaseModel):
|
|
74
|
+
model_config = ConfigDict(extra="forbid")
|
|
75
|
+
|
|
76
|
+
tools: list[str] = Field(default_factory=list)
|
|
77
|
+
chat: list[str] = Field(default_factory=list)
|
|
78
|
+
artifact: list[str] = Field(default_factory=list)
|
|
79
|
+
llm: list[str] = Field(default_factory=list)
|
|
80
|
+
fs: list[str] = Field(default_factory=list)
|
|
81
|
+
storage: list[str] = Field(default_factory=list)
|
|
82
|
+
prefs: list[str] = Field(default_factory=list)
|
|
83
|
+
window: list[str] = Field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class UiManifestSection(BaseModel):
|
|
87
|
+
model_config = ConfigDict(extra="forbid")
|
|
88
|
+
|
|
89
|
+
bundle: UiBundleSection
|
|
90
|
+
views: list[UiViewSpec] = Field(default_factory=list)
|
|
91
|
+
host_api: UiHostApiSpec = Field(default_factory=UiHostApiSpec)
|
|
92
|
+
csp_overrides: dict[str, list[str]] = Field(default_factory=dict)
|
|
93
|
+
state_merge: str = Field("last_writer_wins", max_length=20)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class AppDevConfig(BaseModel):
|
|
97
|
+
"""Optional ``manifest.dev`` block — local-harness-only configuration.
|
|
98
|
+
|
|
99
|
+
Production dispatcher ignores this field at runtime (it is declared here
|
|
100
|
+
so ``extra="forbid"`` doesn't reject it). ``anna-app publish`` SHOULD
|
|
101
|
+
strip ``dev`` from the manifest before upload; mirroring nexus's
|
|
102
|
+
historical "drop unknown keys" behaviour.
|
|
103
|
+
|
|
104
|
+
See design doc §8 for field semantics. All fields are intentionally loose
|
|
105
|
+
(`Any`) so the harness can evolve independently from the wire schema.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
model_config = ConfigDict(extra="allow")
|
|
109
|
+
|
|
110
|
+
fixtures: Optional[list[str]] = Field(
|
|
111
|
+
default=None,
|
|
112
|
+
description="Glob patterns for fixture JSONL files relative to manifest dir.",
|
|
113
|
+
)
|
|
114
|
+
mocks: Optional[dict[str, Any]] = Field(
|
|
115
|
+
default=None,
|
|
116
|
+
description="Per-(ns,method) static response mocks, keyed by 'ns.method'.",
|
|
117
|
+
)
|
|
118
|
+
seed_storage: Optional[dict[str, Any]] = Field(
|
|
119
|
+
default=None,
|
|
120
|
+
description="Initial runtime_state for `anna-app dev` sessions.",
|
|
121
|
+
)
|
|
122
|
+
user_id: Optional[int] = Field(
|
|
123
|
+
default=None,
|
|
124
|
+
description="Override the default harness user_id (default: 1).",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class AppManifest(BaseModel):
|
|
129
|
+
"""App Manifest schema (stored in ``anna_app_versions.manifest``).
|
|
130
|
+
|
|
131
|
+
Adding a field here MUST be coordinated with ``anna-app-schema`` JSON
|
|
132
|
+
Schema bundle and ``DISPATCHER_VERSION`` in `versions.py`.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
model_config = ConfigDict(extra="forbid")
|
|
136
|
+
|
|
137
|
+
schema_: int = Field(1, alias="schema", ge=1, le=2)
|
|
138
|
+
permissions: list[str] = Field(default_factory=list)
|
|
139
|
+
required_executas: list[ManifestExecutaRef] = Field(default_factory=list)
|
|
140
|
+
optional_executas: list[ManifestExecutaRef] = Field(default_factory=list)
|
|
141
|
+
system_prompt_addendum: Optional[str] = Field(None, max_length=4000)
|
|
142
|
+
user_message_prefix_template: Optional[str] = Field(None, max_length=500)
|
|
143
|
+
tags: list[str] = Field(default_factory=list)
|
|
144
|
+
ui: Optional[UiManifestSection] = None
|
|
145
|
+
# Phase 8: harness-only block; production ignores at runtime.
|
|
146
|
+
dev: Optional[AppDevConfig] = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# -----------------------------------------------------------------------------
|
|
150
|
+
# Window view metadata (RPC response shape for window.hello)
|
|
151
|
+
# -----------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class WindowViewMeta(BaseModel):
|
|
155
|
+
name: str
|
|
156
|
+
title: str
|
|
157
|
+
entry: Optional[str] = None
|
|
158
|
+
min_size: Optional[UiSize] = None
|
|
159
|
+
default_size: Optional[UiSize] = None
|
|
160
|
+
max_size: Optional[UiSize] = None
|
|
161
|
+
resizable: bool = True
|
|
162
|
+
movable: bool = True
|
|
163
|
+
single_instance: bool = False
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Window store protocol.
|
|
2
|
+
|
|
3
|
+
The minimum persistence + side-effect surface the RPC dispatcher requires
|
|
4
|
+
from a backing store. Production wires `SqlAlchemyWindowStore(db)` (nexus).
|
|
5
|
+
The local-dev harness provides `InMemoryWindowStore`. Both satisfy this
|
|
6
|
+
contract so the *exact same* dispatcher code runs in either environment.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class WindowStoreProtocol(Protocol):
|
|
16
|
+
"""See ``anna-app-core/README.md`` for the externally-published contract.
|
|
17
|
+
|
|
18
|
+
Heavy operations (`open_sibling_window`, `invoke_executa_tool`) MAY
|
|
19
|
+
raise `NotImplementedError` in stripped-down impls; the dispatcher
|
|
20
|
+
surfaces those as `HostRpcError("not_implemented", ...)`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
async def commit(self) -> None: ...
|
|
24
|
+
|
|
25
|
+
async def get_user(self, user_id: int) -> Any | None: ...
|
|
26
|
+
|
|
27
|
+
async def replace_runtime_state(
|
|
28
|
+
self, *, user: Any, window_uuid: str, state: dict
|
|
29
|
+
) -> None: ...
|
|
30
|
+
|
|
31
|
+
async def emit_event(
|
|
32
|
+
self, user_id: int, kind: str, payload: dict[str, Any]
|
|
33
|
+
) -> None: ...
|
|
34
|
+
|
|
35
|
+
async def open_sibling_window(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
window: Any,
|
|
39
|
+
view_name: str | None,
|
|
40
|
+
payload: dict,
|
|
41
|
+
) -> tuple[Any, bool]: ...
|
|
42
|
+
|
|
43
|
+
async def invoke_executa_tool(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
user_id: int,
|
|
47
|
+
plugin_name: str,
|
|
48
|
+
tool_name: str,
|
|
49
|
+
tool_args: dict,
|
|
50
|
+
) -> Any: ...
|
anna_app_core/runtime.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Pure helper functions over `AppManifest`.
|
|
2
|
+
|
|
3
|
+
Used by the dispatcher (and re-exported by nexus's
|
|
4
|
+
``src/services/anna_app_runtime_service.py`` for backward compatibility).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from .errors import WindowError
|
|
12
|
+
from .manifest import AppManifest, UiViewSpec, WindowViewMeta
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def select_view(manifest: AppManifest, view_name: Optional[str]) -> UiViewSpec:
|
|
16
|
+
"""Pick the ``UiViewSpec`` matching ``view_name`` (or the default view)."""
|
|
17
|
+
if manifest.ui is None or not manifest.ui.views:
|
|
18
|
+
raise WindowError("app has no UI views")
|
|
19
|
+
if view_name is None:
|
|
20
|
+
for v in manifest.ui.views:
|
|
21
|
+
if v.default:
|
|
22
|
+
return v
|
|
23
|
+
return manifest.ui.views[0]
|
|
24
|
+
for v in manifest.ui.views:
|
|
25
|
+
if v.name == view_name:
|
|
26
|
+
return v
|
|
27
|
+
raise WindowError(f"unknown view '{view_name}'")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def scopes_from_manifest(manifest: AppManifest) -> list[str]:
|
|
31
|
+
"""Flatten ``manifest.ui.host_api`` into ``"<ns>.<method>"`` strings."""
|
|
32
|
+
if manifest.ui is None:
|
|
33
|
+
return []
|
|
34
|
+
out: list[str] = []
|
|
35
|
+
api = manifest.ui.host_api
|
|
36
|
+
for ns, methods in {
|
|
37
|
+
"tools": api.tools,
|
|
38
|
+
"chat": api.chat,
|
|
39
|
+
"artifact": api.artifact,
|
|
40
|
+
"llm": api.llm,
|
|
41
|
+
"fs": api.fs,
|
|
42
|
+
"storage": api.storage,
|
|
43
|
+
"prefs": api.prefs,
|
|
44
|
+
"window": api.window,
|
|
45
|
+
}.items():
|
|
46
|
+
for m in methods:
|
|
47
|
+
out.append(f"{ns}.{m}")
|
|
48
|
+
if "window.*" not in out:
|
|
49
|
+
out.append("window.*")
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def view_meta(view: UiViewSpec) -> WindowViewMeta:
|
|
54
|
+
"""Project a ``UiViewSpec`` into the wire-shape ``WindowViewMeta``."""
|
|
55
|
+
return WindowViewMeta(
|
|
56
|
+
name=view.name,
|
|
57
|
+
title=view.title,
|
|
58
|
+
entry=view.entry,
|
|
59
|
+
min_size=view.min_size,
|
|
60
|
+
default_size=view.default_size,
|
|
61
|
+
max_size=view.max_size,
|
|
62
|
+
resizable=view.resizable,
|
|
63
|
+
movable=view.movable,
|
|
64
|
+
single_instance=view.single_instance,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Underscore aliases preserve nexus's historical `runtime._select_view` /
|
|
69
|
+
# `runtime._view_meta` / `runtime._scopes_from_manifest` access patterns so
|
|
70
|
+
# the dispatcher's existing call sites work after the move.
|
|
71
|
+
_select_view = select_view
|
|
72
|
+
_scopes_from_manifest = scopes_from_manifest
|
|
73
|
+
_view_meta = view_meta
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Pinned protocol/SDK versions.
|
|
2
|
+
|
|
3
|
+
These mirror the values in ``packages/anna-app-schema/`` and the central
|
|
4
|
+
``packages/VERSIONS.md`` matrix. Any bump here is a breaking change for
|
|
5
|
+
the wire protocol; coordinate with the schema bundle.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
DISPATCHER_VERSION = "0.1.0"
|
|
9
|
+
SDK_VERSION = "0.1.0"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anna-app-core
|
|
3
|
+
Version: 0.2.0a1
|
|
4
|
+
Summary: Anna App platform core: manifest schemas, RPC dispatcher, protocols, errors. Shared by matrix-nexus and the local-dev runtime.
|
|
5
|
+
Project-URL: Documentation, https://github.com/talentai/matrix-nexus/blob/main/docs/design/anna-app-local-dev-and-test.md
|
|
6
|
+
Author: Talent AI
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: anna,anna-app,executa
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: pydantic<3.0,>=2.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# anna-app-core
|
|
14
|
+
|
|
15
|
+
Shared protocol/error/ACL primitives for the [Anna App](https://github.com/talentai/matrix-nexus/blob/main/docs/design/anna-app-local-dev-and-test.md) platform.
|
|
16
|
+
|
|
17
|
+
This package is intentionally tiny. It exposes the **stable contract surface**
|
|
18
|
+
that both `matrix-nexus` (production host) and `anna-app-runtime-local` (local
|
|
19
|
+
dev harness) can depend on without pulling each other's runtime.
|
|
20
|
+
|
|
21
|
+
## What's here (v0.1.x)
|
|
22
|
+
|
|
23
|
+
- `WindowStoreProtocol` — minimum surface RPC handlers expect from a window store
|
|
24
|
+
- `HostRpcError`, `WindowError`, `WindowPermissionError` — error types crossing the boundary
|
|
25
|
+
- `host_api_allows(manifest_dict, ns, method) -> bool` — pure ACL check (manifest passed as `dict`)
|
|
26
|
+
- `DISPATCHER_VERSION` / `SDK_VERSION` constants
|
|
27
|
+
|
|
28
|
+
## What's NOT here yet
|
|
29
|
+
|
|
30
|
+
- The full `dispatch()` function — still lives in `matrix-nexus`
|
|
31
|
+
(`src/services/anna_app_rpc_dispatcher.py`). Planned for `anna-app-core>=0.2`
|
|
32
|
+
once `event_stream` + `runtime_service` are abstracted behind protocols.
|
|
33
|
+
- Pydantic `AppManifest` — see `anna-app-schema` (JSON Schema bundle).
|
|
34
|
+
|
|
35
|
+
## Versioning
|
|
36
|
+
|
|
37
|
+
`0.1.0a1` is the first PyPI release (alpha). Pin policy lives in the central
|
|
38
|
+
[VERSIONS.md](../VERSIONS.md).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
anna_app_core/__init__.py,sha256=OKAEUfSCtAMs860pcfgnx39kbzXad9TQWmb749Wf6Ew,1394
|
|
2
|
+
anna_app_core/acl.py,sha256=efTr5wK2ooWK57hHyZSQzGwZHVJnbLMHhN9Q9VLOru0,1901
|
|
3
|
+
anna_app_core/dispatcher.py,sha256=PexHuhN2SssG3xK6d_lpxwsFkJPce6G_u1d1khb12TQ,18195
|
|
4
|
+
anna_app_core/errors.py,sha256=of6NxDwzorhaOZy8OukLH-hUZQmaEpZvWYnBNKJG2Oc,1072
|
|
5
|
+
anna_app_core/manifest.py,sha256=BA6BUChfDGoPy8pwK-4d4f3BMFy9YTfjcGL1qhnRv6o,5876
|
|
6
|
+
anna_app_core/protocols.py,sha256=_mqAxmIwJ25uO6f1LU3uqzFI4ZJRsGjmupmjoWTSRq8,1436
|
|
7
|
+
anna_app_core/runtime.py,sha256=cEY32RUQ2h5Ynz8z8DDascl6UzvE0C3UkSl4DGF29r0,2288
|
|
8
|
+
anna_app_core/versions.py,sha256=moCI1M1yAofqmJjErP9a64yEIJTZkq07zvYF8cAIYIE,289
|
|
9
|
+
anna_app_core-0.2.0a1.dist-info/METADATA,sha256=PnNWhPQ-fKc_2aTmq_FrBq9iArcvI2yhMvZzimtxU3M,1681
|
|
10
|
+
anna_app_core-0.2.0a1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
anna_app_core-0.2.0a1.dist-info/RECORD,,
|