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.
@@ -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}")
@@ -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: ...
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any