anna-app-runtime-local 0.2.0a1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anna_app_runtime_local-0.2.0a1/.gitignore +167 -0
- anna_app_runtime_local-0.2.0a1/PKG-INFO +78 -0
- anna_app_runtime_local-0.2.0a1/README.md +67 -0
- anna_app_runtime_local-0.2.0a1/pyproject.toml +26 -0
- anna_app_runtime_local-0.2.0a1/src/anna_app_runtime_local/__init__.py +29 -0
- anna_app_runtime_local-0.2.0a1/src/anna_app_runtime_local/bridge.py +236 -0
- anna_app_runtime_local-0.2.0a1/src/anna_app_runtime_local/executa.py +223 -0
- anna_app_runtime_local-0.2.0a1/src/anna_app_runtime_local/session.py +114 -0
- anna_app_runtime_local-0.2.0a1/src/anna_app_runtime_local/store.py +178 -0
- anna_app_runtime_local-0.2.0a1/src/anna_app_runtime_local/token.py +113 -0
- anna_app_runtime_local-0.2.0a1/tests/__init__.py +0 -0
- anna_app_runtime_local-0.2.0a1/tests/conftest.py +21 -0
- anna_app_runtime_local-0.2.0a1/tests/test_local_dispatch.py +282 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# matrix agent repository
|
|
2
|
+
matrix/
|
|
3
|
+
|
|
4
|
+
# git diff files
|
|
5
|
+
git.diff
|
|
6
|
+
|
|
7
|
+
# Byte-compiled / optimized / DLL files
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.py[cod]
|
|
10
|
+
*$py.class
|
|
11
|
+
|
|
12
|
+
# C extensions
|
|
13
|
+
*.so
|
|
14
|
+
|
|
15
|
+
# Distribution / packaging
|
|
16
|
+
.Python
|
|
17
|
+
build/
|
|
18
|
+
develop-eggs/
|
|
19
|
+
dist/
|
|
20
|
+
downloads/
|
|
21
|
+
eggs/
|
|
22
|
+
.eggs/
|
|
23
|
+
lib/
|
|
24
|
+
lib64/
|
|
25
|
+
parts/
|
|
26
|
+
sdist/
|
|
27
|
+
var/
|
|
28
|
+
wheels/
|
|
29
|
+
share/python-wheels/
|
|
30
|
+
*.egg-info/
|
|
31
|
+
.installed.cfg
|
|
32
|
+
*.egg
|
|
33
|
+
# setuptools sdist MANIFEST (only at repo root — must NOT swallow the
|
|
34
|
+
# packages/anna-app-schema/manifest/ schema bundle directory).
|
|
35
|
+
/MANIFEST
|
|
36
|
+
|
|
37
|
+
# PyInstaller
|
|
38
|
+
# Anchored to repo root for the same reason — `*.manifest` is too broad
|
|
39
|
+
# and was matching schema bundle files. Re-add specific paths if a real
|
|
40
|
+
# PyInstaller .manifest ever ships.
|
|
41
|
+
/*.manifest
|
|
42
|
+
*.spec
|
|
43
|
+
|
|
44
|
+
# Installer logs
|
|
45
|
+
pip-log.txt
|
|
46
|
+
pip-delete-this-directory.txt
|
|
47
|
+
|
|
48
|
+
# Unit test / coverage reports
|
|
49
|
+
htmlcov/
|
|
50
|
+
.tox/
|
|
51
|
+
.nox/
|
|
52
|
+
.coverage
|
|
53
|
+
.coverage.*
|
|
54
|
+
.cache
|
|
55
|
+
nosetests.xml
|
|
56
|
+
coverage.xml
|
|
57
|
+
*.cover
|
|
58
|
+
*.py,cover
|
|
59
|
+
.hypothesis/
|
|
60
|
+
.pytest_cache/
|
|
61
|
+
cover/
|
|
62
|
+
|
|
63
|
+
# Translations
|
|
64
|
+
*.mo
|
|
65
|
+
*.pot
|
|
66
|
+
|
|
67
|
+
# Django stuff:
|
|
68
|
+
*.log
|
|
69
|
+
local_settings.py
|
|
70
|
+
db.sqlite3
|
|
71
|
+
db.sqlite3-journal
|
|
72
|
+
|
|
73
|
+
# Flask stuff:
|
|
74
|
+
instance/
|
|
75
|
+
.webassets-cache
|
|
76
|
+
|
|
77
|
+
# Scrapy stuff:
|
|
78
|
+
.scrapy
|
|
79
|
+
|
|
80
|
+
# Sphinx documentation
|
|
81
|
+
docs/_build/
|
|
82
|
+
|
|
83
|
+
# PyBuilder
|
|
84
|
+
.pybuilder/
|
|
85
|
+
target/
|
|
86
|
+
|
|
87
|
+
# Jupyter Notebook
|
|
88
|
+
.ipynb_checkpoints
|
|
89
|
+
|
|
90
|
+
# IPython
|
|
91
|
+
profile_default/
|
|
92
|
+
ipython_config.py
|
|
93
|
+
|
|
94
|
+
# pyenv
|
|
95
|
+
.python-version
|
|
96
|
+
|
|
97
|
+
# pipenv
|
|
98
|
+
Pipfile.lock
|
|
99
|
+
|
|
100
|
+
# poetry
|
|
101
|
+
poetry.lock
|
|
102
|
+
|
|
103
|
+
# pdm
|
|
104
|
+
.pdm.toml
|
|
105
|
+
|
|
106
|
+
# PEP 582
|
|
107
|
+
__pypackages__/
|
|
108
|
+
|
|
109
|
+
# Celery stuff
|
|
110
|
+
celerybeat-schedule
|
|
111
|
+
celerybeat.pid
|
|
112
|
+
|
|
113
|
+
# SageMath parsed files
|
|
114
|
+
*.sage.py
|
|
115
|
+
|
|
116
|
+
# Environments
|
|
117
|
+
.env
|
|
118
|
+
.venv
|
|
119
|
+
env/
|
|
120
|
+
venv/
|
|
121
|
+
ENV/
|
|
122
|
+
env.bak/
|
|
123
|
+
venv.bak/
|
|
124
|
+
|
|
125
|
+
# Spyder project settings
|
|
126
|
+
.spyderproject
|
|
127
|
+
.spyproject
|
|
128
|
+
|
|
129
|
+
# Rope project settings
|
|
130
|
+
.ropeproject
|
|
131
|
+
|
|
132
|
+
# mkdocs documentation
|
|
133
|
+
/site
|
|
134
|
+
|
|
135
|
+
# mypy
|
|
136
|
+
.mypy_cache/
|
|
137
|
+
.dmypy.json
|
|
138
|
+
dmypy.json
|
|
139
|
+
|
|
140
|
+
# Pyre type checker
|
|
141
|
+
.pyre/
|
|
142
|
+
|
|
143
|
+
# pytype static type analyzer
|
|
144
|
+
.pytype/
|
|
145
|
+
|
|
146
|
+
# Cython debug symbols
|
|
147
|
+
cython_debug/
|
|
148
|
+
|
|
149
|
+
# IDE
|
|
150
|
+
.idea/
|
|
151
|
+
*.swp
|
|
152
|
+
*.swo
|
|
153
|
+
*~
|
|
154
|
+
.DS_Store
|
|
155
|
+
|
|
156
|
+
# Project specific
|
|
157
|
+
*.tmp
|
|
158
|
+
*.bak
|
|
159
|
+
|
|
160
|
+
# Security - RSA keys
|
|
161
|
+
private_key.pem
|
|
162
|
+
*.pem
|
|
163
|
+
!public_key.pem
|
|
164
|
+
|
|
165
|
+
scripts/nats_auth
|
|
166
|
+
# Anna App runtime cache
|
|
167
|
+
static/anna-apps-cache/
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anna-app-runtime-local
|
|
3
|
+
Version: 0.2.0a1
|
|
4
|
+
Summary: Local-dev in-memory runtime for Anna Apps. Wraps anna-app-core's dispatcher with an InMemoryWindowStore + WebSocket bridge.
|
|
5
|
+
Author: Talent AI
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Keywords: anna,anna-app,dev,executa,harness
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: anna-app-core<0.3,>=0.2.0a1
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# anna-app-runtime-local
|
|
13
|
+
|
|
14
|
+
Local-dev in-memory runtime for Anna Apps. Reuses the production
|
|
15
|
+
[`anna_app_rpc_dispatcher`](../../src/services/anna_app_rpc_dispatcher.py)
|
|
16
|
+
through `WindowStoreProtocol` so harness behaviour is byte-identical to
|
|
17
|
+
nexus production.
|
|
18
|
+
|
|
19
|
+
## How it shares code with production
|
|
20
|
+
|
|
21
|
+
This package does not vendor a copy of the dispatcher. Instead it:
|
|
22
|
+
|
|
23
|
+
1. Imports `dispatch`, `WindowStoreProtocol`, `HostRpcError` from
|
|
24
|
+
`src.services.anna_app_rpc_dispatcher` (matrix-nexus source).
|
|
25
|
+
2. Provides `InMemoryWindowStore` — a `WindowStoreProtocol` impl that
|
|
26
|
+
keeps state in dictionaries and queues SSE events in memory.
|
|
27
|
+
3. Wires the production `dispatch(store, …)` against in-memory state.
|
|
28
|
+
|
|
29
|
+
Because the wheel imports `src.services.…` directly, the bridge process
|
|
30
|
+
must be launched from a Python environment that has matrix-nexus on
|
|
31
|
+
`PYTHONPATH` (typically: `cd matrix-nexus && uv run python -m
|
|
32
|
+
anna_app_runtime_local.bridge`).
|
|
33
|
+
|
|
34
|
+
## Public surface
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from anna_app_runtime_local import LocalDispatcherSession, mint_dev_token
|
|
38
|
+
|
|
39
|
+
session = LocalDispatcherSession.create(
|
|
40
|
+
user_id=1,
|
|
41
|
+
app_slug="focus-flow",
|
|
42
|
+
manifest_dict={...}, # parsed manifest.json
|
|
43
|
+
view="main",
|
|
44
|
+
entry_payload={"topic": "ECM"},
|
|
45
|
+
)
|
|
46
|
+
result = await session.call("storage", "set", {"key": "x", "value": 42})
|
|
47
|
+
events = session.drain_events() # list[dict] for SSE relay
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## stdio bridge
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python -m anna_app_runtime_local.bridge
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Speaks JSON-RPC 2.0 over stdin/stdout (one envelope per line). Methods:
|
|
57
|
+
|
|
58
|
+
- `session.create` → `{ session_id, window_uuid, token, view, view_meta }`
|
|
59
|
+
- `session.call` → forwards to `dispatch()`; returns `{ ok, result | error }`
|
|
60
|
+
- `session.drain_events` → flushes queued SSE events
|
|
61
|
+
- `session.close` → drops the session
|
|
62
|
+
- `session.refresh_token` → re-mints a dev token for an active session
|
|
63
|
+
- `executas.register` → registers `{tool_id, project_dir, command?}` for
|
|
64
|
+
`tools.invoke`; first call lazy-spawns `uv run --project <dir> <tool_id>`
|
|
65
|
+
and reuses the warm subprocess.
|
|
66
|
+
|
|
67
|
+
The Node-side harness (`anna-app-cli/src/harness/bridge.ts`) speaks this
|
|
68
|
+
protocol over `python-shell`.
|
|
69
|
+
|
|
70
|
+
## Local HMAC tokens
|
|
71
|
+
|
|
72
|
+
`mint_dev_token / verify_dev_token` use a per-user key at
|
|
73
|
+
`~/.anna-app/dev.key` (mode 600, generated on first use). TTL defaults
|
|
74
|
+
to 30 s — shorter than production's 120 s — to surface SDK refresh bugs
|
|
75
|
+
early.
|
|
76
|
+
|
|
77
|
+
These tokens are **not interoperable** with the production JWT path;
|
|
78
|
+
production's `_is_tool_allowed` already rejects `tool-dev-…` prefixes.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# anna-app-runtime-local
|
|
2
|
+
|
|
3
|
+
Local-dev in-memory runtime for Anna Apps. Reuses the production
|
|
4
|
+
[`anna_app_rpc_dispatcher`](../../src/services/anna_app_rpc_dispatcher.py)
|
|
5
|
+
through `WindowStoreProtocol` so harness behaviour is byte-identical to
|
|
6
|
+
nexus production.
|
|
7
|
+
|
|
8
|
+
## How it shares code with production
|
|
9
|
+
|
|
10
|
+
This package does not vendor a copy of the dispatcher. Instead it:
|
|
11
|
+
|
|
12
|
+
1. Imports `dispatch`, `WindowStoreProtocol`, `HostRpcError` from
|
|
13
|
+
`src.services.anna_app_rpc_dispatcher` (matrix-nexus source).
|
|
14
|
+
2. Provides `InMemoryWindowStore` — a `WindowStoreProtocol` impl that
|
|
15
|
+
keeps state in dictionaries and queues SSE events in memory.
|
|
16
|
+
3. Wires the production `dispatch(store, …)` against in-memory state.
|
|
17
|
+
|
|
18
|
+
Because the wheel imports `src.services.…` directly, the bridge process
|
|
19
|
+
must be launched from a Python environment that has matrix-nexus on
|
|
20
|
+
`PYTHONPATH` (typically: `cd matrix-nexus && uv run python -m
|
|
21
|
+
anna_app_runtime_local.bridge`).
|
|
22
|
+
|
|
23
|
+
## Public surface
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from anna_app_runtime_local import LocalDispatcherSession, mint_dev_token
|
|
27
|
+
|
|
28
|
+
session = LocalDispatcherSession.create(
|
|
29
|
+
user_id=1,
|
|
30
|
+
app_slug="focus-flow",
|
|
31
|
+
manifest_dict={...}, # parsed manifest.json
|
|
32
|
+
view="main",
|
|
33
|
+
entry_payload={"topic": "ECM"},
|
|
34
|
+
)
|
|
35
|
+
result = await session.call("storage", "set", {"key": "x", "value": 42})
|
|
36
|
+
events = session.drain_events() # list[dict] for SSE relay
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## stdio bridge
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
python -m anna_app_runtime_local.bridge
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Speaks JSON-RPC 2.0 over stdin/stdout (one envelope per line). Methods:
|
|
46
|
+
|
|
47
|
+
- `session.create` → `{ session_id, window_uuid, token, view, view_meta }`
|
|
48
|
+
- `session.call` → forwards to `dispatch()`; returns `{ ok, result | error }`
|
|
49
|
+
- `session.drain_events` → flushes queued SSE events
|
|
50
|
+
- `session.close` → drops the session
|
|
51
|
+
- `session.refresh_token` → re-mints a dev token for an active session
|
|
52
|
+
- `executas.register` → registers `{tool_id, project_dir, command?}` for
|
|
53
|
+
`tools.invoke`; first call lazy-spawns `uv run --project <dir> <tool_id>`
|
|
54
|
+
and reuses the warm subprocess.
|
|
55
|
+
|
|
56
|
+
The Node-side harness (`anna-app-cli/src/harness/bridge.ts`) speaks this
|
|
57
|
+
protocol over `python-shell`.
|
|
58
|
+
|
|
59
|
+
## Local HMAC tokens
|
|
60
|
+
|
|
61
|
+
`mint_dev_token / verify_dev_token` use a per-user key at
|
|
62
|
+
`~/.anna-app/dev.key` (mode 600, generated on first use). TTL defaults
|
|
63
|
+
to 30 s — shorter than production's 120 s — to surface SDK refresh bugs
|
|
64
|
+
early.
|
|
65
|
+
|
|
66
|
+
These tokens are **not interoperable** with the production JWT path;
|
|
67
|
+
production's `_is_tool_allowed` already rejects `tool-dev-…` prefixes.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "anna-app-runtime-local"
|
|
3
|
+
version = "0.2.0a1"
|
|
4
|
+
description = "Local-dev in-memory runtime for Anna Apps. Wraps anna-app-core's dispatcher with an InMemoryWindowStore + WebSocket bridge."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "Proprietary" }
|
|
8
|
+
authors = [{ name = "Talent AI" }]
|
|
9
|
+
keywords = ["anna", "anna-app", "executa", "dev", "harness"]
|
|
10
|
+
|
|
11
|
+
# v0.2 — truly standalone. Runtime deps:
|
|
12
|
+
# * `anna-app-core` provides the dispatcher + manifest schema + protocol.
|
|
13
|
+
# * Everything else (bridge, store, token mint) is pure stdlib.
|
|
14
|
+
dependencies = [
|
|
15
|
+
"anna-app-core>=0.2.0a1,<0.3",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
anna-app-bridge = "anna_app_runtime_local.bridge:main"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/anna_app_runtime_local"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Local-dev in-memory runtime for Anna Apps.
|
|
2
|
+
|
|
3
|
+
See README.md for architecture notes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .executa import ExecutaPool, ExecutaSpec
|
|
9
|
+
from .session import LocalDispatcherSession, LocalSessionError
|
|
10
|
+
from .store import InMemoryWindowSession, InMemoryWindowStore
|
|
11
|
+
from .token import (
|
|
12
|
+
DevTokenError,
|
|
13
|
+
mint_dev_token,
|
|
14
|
+
verify_dev_token,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"LocalDispatcherSession",
|
|
19
|
+
"LocalSessionError",
|
|
20
|
+
"InMemoryWindowStore",
|
|
21
|
+
"InMemoryWindowSession",
|
|
22
|
+
"ExecutaPool",
|
|
23
|
+
"ExecutaSpec",
|
|
24
|
+
"mint_dev_token",
|
|
25
|
+
"verify_dev_token",
|
|
26
|
+
"DevTokenError",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Stdio JSON-RPC 2.0 bridge for the anna-app-cli harness.
|
|
2
|
+
|
|
3
|
+
Spoken by `anna-app-cli/src/harness/bridge.ts` over `python-shell`.
|
|
4
|
+
|
|
5
|
+
Protocol: one JSON envelope per line on stdin / stdout.
|
|
6
|
+
|
|
7
|
+
Methods (request → response):
|
|
8
|
+
|
|
9
|
+
session.create { user_id, manifest, view?, entry_payload?, app_slug? }
|
|
10
|
+
→ { session_id, window_uuid, token, expires_in }
|
|
11
|
+
|
|
12
|
+
session.call { session_id, ns, method, args? }
|
|
13
|
+
→ { ok: true, result: <dict> }
|
|
14
|
+
| { ok: false, error: { code, message, details } }
|
|
15
|
+
|
|
16
|
+
session.drain_events { session_id }
|
|
17
|
+
→ { events: [<event>, ...] }
|
|
18
|
+
|
|
19
|
+
session.close { session_id }
|
|
20
|
+
→ { ok: true }
|
|
21
|
+
|
|
22
|
+
session.refresh_token { session_id }
|
|
23
|
+
→ { token, expires_in }
|
|
24
|
+
|
|
25
|
+
executas.register { executas: [{tool_id, project_dir, command?}, ...] }
|
|
26
|
+
→ { registered: [tool_id, ...] }
|
|
27
|
+
|
|
28
|
+
ping {} → { pong: true }
|
|
29
|
+
|
|
30
|
+
All other request methods → JSON-RPC error -32601 (Method not found).
|
|
31
|
+
Malformed envelopes → -32700.
|
|
32
|
+
|
|
33
|
+
Exit cleanly on EOF.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import asyncio
|
|
39
|
+
import json
|
|
40
|
+
import sys
|
|
41
|
+
import traceback
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
from .executa import ExecutaPool, ExecutaSpec
|
|
45
|
+
from .session import LocalDispatcherSession, LocalSessionError
|
|
46
|
+
from .store import InMemoryWindowStore
|
|
47
|
+
from .token import mint_dev_token
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _Bridge:
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self._sessions: dict[str, LocalDispatcherSession] = {}
|
|
53
|
+
self._next_id = 1
|
|
54
|
+
# Process-wide singletons: one executa pool + one in-memory store
|
|
55
|
+
# shared by every harness session (mirrors how production has a
|
|
56
|
+
# single dispatcher per user).
|
|
57
|
+
self._executa_pool = ExecutaPool()
|
|
58
|
+
self._store = InMemoryWindowStore(executa_pool=self._executa_pool)
|
|
59
|
+
|
|
60
|
+
def _new_session_id(self) -> str:
|
|
61
|
+
sid = f"sess-{self._next_id}"
|
|
62
|
+
self._next_id += 1
|
|
63
|
+
return sid
|
|
64
|
+
|
|
65
|
+
async def handle(self, req: dict[str, Any]) -> dict[str, Any]:
|
|
66
|
+
method = req.get("method")
|
|
67
|
+
params = req.get("params") or {}
|
|
68
|
+
rpc_id = req.get("id")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
if method == "ping":
|
|
72
|
+
result: Any = {"pong": True}
|
|
73
|
+
|
|
74
|
+
elif method == "session.create":
|
|
75
|
+
session = LocalDispatcherSession.create(
|
|
76
|
+
user_id=int(params.get("user_id", 1)),
|
|
77
|
+
manifest_dict=params["manifest"],
|
|
78
|
+
view=params.get("view"),
|
|
79
|
+
entry_payload=params.get("entry_payload"),
|
|
80
|
+
runtime_state=params.get("runtime_state"),
|
|
81
|
+
app_slug=params.get("app_slug"),
|
|
82
|
+
store=self._store,
|
|
83
|
+
)
|
|
84
|
+
sid = self._new_session_id()
|
|
85
|
+
self._sessions[sid] = session
|
|
86
|
+
token, expires_in = mint_dev_token(
|
|
87
|
+
window_uuid=session.window_uuid,
|
|
88
|
+
user_id=session.window.user_id,
|
|
89
|
+
app_id=session.window.app_id,
|
|
90
|
+
version_id=session.window.version_id,
|
|
91
|
+
)
|
|
92
|
+
result = {
|
|
93
|
+
"session_id": sid,
|
|
94
|
+
"window_uuid": session.window_uuid,
|
|
95
|
+
"token": token,
|
|
96
|
+
"expires_in": expires_in,
|
|
97
|
+
"view": session.window.view,
|
|
98
|
+
"view_meta": _view_meta(session),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
elif method == "session.call":
|
|
102
|
+
sid = params["session_id"]
|
|
103
|
+
session = self._sessions.get(sid)
|
|
104
|
+
if session is None:
|
|
105
|
+
return _err(rpc_id, -32602, f"unknown session_id: {sid}")
|
|
106
|
+
try:
|
|
107
|
+
out = await session.call(
|
|
108
|
+
params["ns"],
|
|
109
|
+
params["method"],
|
|
110
|
+
params.get("args") or {},
|
|
111
|
+
)
|
|
112
|
+
result = {"ok": True, "result": out}
|
|
113
|
+
except LocalSessionError as e:
|
|
114
|
+
result = {"ok": False, "error": e.to_dict()}
|
|
115
|
+
|
|
116
|
+
elif method == "session.drain_events":
|
|
117
|
+
sid = params["session_id"]
|
|
118
|
+
session = self._sessions.get(sid)
|
|
119
|
+
if session is None:
|
|
120
|
+
return _err(rpc_id, -32602, f"unknown session_id: {sid}")
|
|
121
|
+
result = {"events": session.drain_events()}
|
|
122
|
+
|
|
123
|
+
elif method == "session.close":
|
|
124
|
+
sid = params["session_id"]
|
|
125
|
+
self._sessions.pop(sid, None)
|
|
126
|
+
result = {"ok": True}
|
|
127
|
+
|
|
128
|
+
elif method == "session.refresh_token":
|
|
129
|
+
sid = params["session_id"]
|
|
130
|
+
session = self._sessions.get(sid)
|
|
131
|
+
if session is None:
|
|
132
|
+
return _err(rpc_id, -32602, f"unknown session_id: {sid}")
|
|
133
|
+
token, expires_in = mint_dev_token(
|
|
134
|
+
window_uuid=session.window_uuid,
|
|
135
|
+
user_id=session.window.user_id,
|
|
136
|
+
app_id=session.window.app_id,
|
|
137
|
+
version_id=session.window.version_id,
|
|
138
|
+
)
|
|
139
|
+
result = {"token": token, "expires_in": expires_in}
|
|
140
|
+
|
|
141
|
+
elif method == "executas.register":
|
|
142
|
+
# params: {executas: [{tool_id, project_dir, command?}, ...]}
|
|
143
|
+
items = params.get("executas") or []
|
|
144
|
+
registered: list[str] = []
|
|
145
|
+
for it in items:
|
|
146
|
+
spec = ExecutaSpec(
|
|
147
|
+
tool_id=it["tool_id"],
|
|
148
|
+
project_dir=it["project_dir"],
|
|
149
|
+
command=it.get("command"),
|
|
150
|
+
)
|
|
151
|
+
self._executa_pool.register(spec)
|
|
152
|
+
registered.append(spec.tool_id)
|
|
153
|
+
result = {"registered": registered}
|
|
154
|
+
|
|
155
|
+
else:
|
|
156
|
+
return _err(rpc_id, -32601, f"method not found: {method}")
|
|
157
|
+
|
|
158
|
+
return {"jsonrpc": "2.0", "id": rpc_id, "result": result}
|
|
159
|
+
|
|
160
|
+
except KeyError as e:
|
|
161
|
+
return _err(rpc_id, -32602, f"missing required param: {e}")
|
|
162
|
+
except Exception as e: # pragma: no cover — defensive
|
|
163
|
+
return _err(
|
|
164
|
+
rpc_id,
|
|
165
|
+
-32603,
|
|
166
|
+
f"internal: {e}",
|
|
167
|
+
{"traceback": traceback.format_exc()},
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _err(
|
|
172
|
+
rpc_id: Any, code: int, message: str, data: dict | None = None
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
err: dict[str, Any] = {"code": code, "message": message}
|
|
175
|
+
if data:
|
|
176
|
+
err["data"] = data
|
|
177
|
+
return {"jsonrpc": "2.0", "id": rpc_id, "error": err}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _view_meta(session: LocalDispatcherSession) -> dict[str, Any] | None:
|
|
181
|
+
"""Return the chosen view's metadata as a plain dict (for harness sizing)."""
|
|
182
|
+
ui = session.manifest.ui
|
|
183
|
+
if ui is None:
|
|
184
|
+
return None
|
|
185
|
+
for v in ui.views:
|
|
186
|
+
if v.name == session.window.view:
|
|
187
|
+
return v.model_dump(mode="json", exclude_none=True)
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def _run() -> None:
|
|
192
|
+
bridge = _Bridge()
|
|
193
|
+
loop = asyncio.get_event_loop()
|
|
194
|
+
reader = asyncio.StreamReader()
|
|
195
|
+
protocol = asyncio.StreamReaderProtocol(reader)
|
|
196
|
+
await loop.connect_read_pipe(lambda: protocol, sys.stdin)
|
|
197
|
+
|
|
198
|
+
# Announce readiness so the Node side can start sending.
|
|
199
|
+
sys.stdout.write(
|
|
200
|
+
json.dumps({"jsonrpc": "2.0", "method": "_ready", "params": {}}) + "\n"
|
|
201
|
+
)
|
|
202
|
+
sys.stdout.flush()
|
|
203
|
+
|
|
204
|
+
while True:
|
|
205
|
+
line = await reader.readline()
|
|
206
|
+
if not line:
|
|
207
|
+
return
|
|
208
|
+
text = line.decode("utf-8").strip()
|
|
209
|
+
if not text:
|
|
210
|
+
continue
|
|
211
|
+
try:
|
|
212
|
+
req = json.loads(text)
|
|
213
|
+
except json.JSONDecodeError:
|
|
214
|
+
sys.stdout.write(json.dumps(_err(None, -32700, "parse error")) + "\n")
|
|
215
|
+
sys.stdout.flush()
|
|
216
|
+
continue
|
|
217
|
+
if not isinstance(req, dict):
|
|
218
|
+
sys.stdout.write(
|
|
219
|
+
json.dumps(_err(None, -32600, "request must be an object")) + "\n"
|
|
220
|
+
)
|
|
221
|
+
sys.stdout.flush()
|
|
222
|
+
continue
|
|
223
|
+
resp = await bridge.handle(req)
|
|
224
|
+
sys.stdout.write(json.dumps(resp) + "\n")
|
|
225
|
+
sys.stdout.flush()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def main() -> None:
|
|
229
|
+
try:
|
|
230
|
+
asyncio.run(_run())
|
|
231
|
+
except KeyboardInterrupt:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
main()
|