appecosystem-hub 0.1.0__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.
- appecosystem_hub-0.1.0/LICENSE +22 -0
- appecosystem_hub-0.1.0/PKG-INFO +39 -0
- appecosystem_hub-0.1.0/README.md +11 -0
- appecosystem_hub-0.1.0/appecosystem_hub.egg-info/PKG-INFO +39 -0
- appecosystem_hub-0.1.0/appecosystem_hub.egg-info/SOURCES.txt +13 -0
- appecosystem_hub-0.1.0/appecosystem_hub.egg-info/dependency_links.txt +1 -0
- appecosystem_hub-0.1.0/appecosystem_hub.egg-info/requires.txt +7 -0
- appecosystem_hub-0.1.0/appecosystem_hub.egg-info/top_level.txt +1 -0
- appecosystem_hub-0.1.0/ecosystem_hub/__init__.py +19 -0
- appecosystem_hub-0.1.0/ecosystem_hub/capabilities.py +225 -0
- appecosystem_hub-0.1.0/ecosystem_hub/lock.py +152 -0
- appecosystem_hub-0.1.0/ecosystem_hub/outbox.py +191 -0
- appecosystem_hub-0.1.0/ecosystem_hub/runtime.py +458 -0
- appecosystem_hub-0.1.0/pyproject.toml +42 -0
- appecosystem_hub-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Smart Industries LLC (Mikel Smart)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: appecosystem-hub
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Embedded/sidecar Hub runtime, single-host lock, capability registry, and durable offline outbox for appEcosystem
|
|
5
|
+
Author: Mikel Smart
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/M1K31/appEcosystem
|
|
8
|
+
Project-URL: Source, https://github.com/M1K31/appEcosystem
|
|
9
|
+
Project-URL: Issues, https://github.com/M1K31/appEcosystem/issues
|
|
10
|
+
Keywords: appecosystem,service-mesh,capabilities,notifications,embedded-hub
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: httpx>=0.24.0
|
|
22
|
+
Requires-Dist: appecosystem-auth>=0.4.0
|
|
23
|
+
Requires-Dist: appecosystem-client>=0.4.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# `appecosystem-hub` (`ecosystem_hub`)
|
|
30
|
+
|
|
31
|
+
App-embeddable Hub lifecycle manager, single-host lock, capability registry, and SQLite durable notification outbox for `appEcosystem`.
|
|
32
|
+
|
|
33
|
+
## Role Modes (`ECOSYSTEM_HUB_ROLE`)
|
|
34
|
+
|
|
35
|
+
- `auto` *(default)*: Discovers an existing Hub (`registry → mDNS → static`); if none responds and the device is at least Tier 1 (`>= 4 GB` RAM), acquires a single-host advisory file lock and launches a managed Hub sidecar.
|
|
36
|
+
- `host`: Explicitly hosts the Hub on this device (falling back to `join` if another local app already holds the single-host lock and passes `/health`).
|
|
37
|
+
- `join`: Joins an existing Hub when reachable; degrades gracefully to local standalone operation + durable SQLite outbox when offline.
|
|
38
|
+
- `off`: Pure standalone operation with zero network discovery or hosting.
|
|
39
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# `appecosystem-hub` (`ecosystem_hub`)
|
|
2
|
+
|
|
3
|
+
App-embeddable Hub lifecycle manager, single-host lock, capability registry, and SQLite durable notification outbox for `appEcosystem`.
|
|
4
|
+
|
|
5
|
+
## Role Modes (`ECOSYSTEM_HUB_ROLE`)
|
|
6
|
+
|
|
7
|
+
- `auto` *(default)*: Discovers an existing Hub (`registry → mDNS → static`); if none responds and the device is at least Tier 1 (`>= 4 GB` RAM), acquires a single-host advisory file lock and launches a managed Hub sidecar.
|
|
8
|
+
- `host`: Explicitly hosts the Hub on this device (falling back to `join` if another local app already holds the single-host lock and passes `/health`).
|
|
9
|
+
- `join`: Joins an existing Hub when reachable; degrades gracefully to local standalone operation + durable SQLite outbox when offline.
|
|
10
|
+
- `off`: Pure standalone operation with zero network discovery or hosting.
|
|
11
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: appecosystem-hub
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Embedded/sidecar Hub runtime, single-host lock, capability registry, and durable offline outbox for appEcosystem
|
|
5
|
+
Author: Mikel Smart
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/M1K31/appEcosystem
|
|
8
|
+
Project-URL: Source, https://github.com/M1K31/appEcosystem
|
|
9
|
+
Project-URL: Issues, https://github.com/M1K31/appEcosystem/issues
|
|
10
|
+
Keywords: appecosystem,service-mesh,capabilities,notifications,embedded-hub
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: httpx>=0.24.0
|
|
22
|
+
Requires-Dist: appecosystem-auth>=0.4.0
|
|
23
|
+
Requires-Dist: appecosystem-client>=0.4.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# `appecosystem-hub` (`ecosystem_hub`)
|
|
30
|
+
|
|
31
|
+
App-embeddable Hub lifecycle manager, single-host lock, capability registry, and SQLite durable notification outbox for `appEcosystem`.
|
|
32
|
+
|
|
33
|
+
## Role Modes (`ECOSYSTEM_HUB_ROLE`)
|
|
34
|
+
|
|
35
|
+
- `auto` *(default)*: Discovers an existing Hub (`registry → mDNS → static`); if none responds and the device is at least Tier 1 (`>= 4 GB` RAM), acquires a single-host advisory file lock and launches a managed Hub sidecar.
|
|
36
|
+
- `host`: Explicitly hosts the Hub on this device (falling back to `join` if another local app already holds the single-host lock and passes `/health`).
|
|
37
|
+
- `join`: Joins an existing Hub when reachable; degrades gracefully to local standalone operation + durable SQLite outbox when offline.
|
|
38
|
+
- `off`: Pure standalone operation with zero network discovery or hosting.
|
|
39
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
appecosystem_hub.egg-info/PKG-INFO
|
|
5
|
+
appecosystem_hub.egg-info/SOURCES.txt
|
|
6
|
+
appecosystem_hub.egg-info/dependency_links.txt
|
|
7
|
+
appecosystem_hub.egg-info/requires.txt
|
|
8
|
+
appecosystem_hub.egg-info/top_level.txt
|
|
9
|
+
ecosystem_hub/__init__.py
|
|
10
|
+
ecosystem_hub/capabilities.py
|
|
11
|
+
ecosystem_hub/lock.py
|
|
12
|
+
ecosystem_hub/outbox.py
|
|
13
|
+
ecosystem_hub/runtime.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ecosystem_hub
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""App-embeddable HubRuntime, SingleHostLock, CapabilityRegistry, and DurableOutbox."""
|
|
2
|
+
from .capabilities import CapabilityRegistry, CapabilityResolution
|
|
3
|
+
from .lock import SingleHostLock, default_hub_lock_path
|
|
4
|
+
from .outbox import DurableOutbox, default_outbox_path
|
|
5
|
+
from .runtime import HubRole, HubRuntime
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"CapabilityRegistry",
|
|
11
|
+
"CapabilityResolution",
|
|
12
|
+
"DurableOutbox",
|
|
13
|
+
"HubRole",
|
|
14
|
+
"HubRuntime",
|
|
15
|
+
"SingleHostLock",
|
|
16
|
+
"default_hub_lock_path",
|
|
17
|
+
"default_outbox_path",
|
|
18
|
+
]
|
|
19
|
+
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Project-contributed capability registry with explicit withdrawal & unavailability reasons."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ecosystem_client.contracts import assert_valid_schema
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class CapabilityResolution:
|
|
18
|
+
"""Result of resolving a capability name for an automation, UI control, or voice intent."""
|
|
19
|
+
|
|
20
|
+
capability: str
|
|
21
|
+
available: bool
|
|
22
|
+
providers: list[dict[str, Any]] = field(default_factory=list)
|
|
23
|
+
unavailable_reason: str | None = None
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"capability": self.capability,
|
|
28
|
+
"available": self.available,
|
|
29
|
+
"providers": self.providers,
|
|
30
|
+
"unavailable_reason": self.unavailable_reason,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CapabilityRegistry:
|
|
35
|
+
"""Stores validated capability-manifest.v1 entries and tracks provider availability.
|
|
36
|
+
|
|
37
|
+
Invariant: when a provider deregisters, withdraws, or fails health checks, dependent
|
|
38
|
+
capabilities report `available=False` with an explicit `unavailable_reason` rather
|
|
39
|
+
than silently disappearing or granting another provider expanded authority.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, persistence_path: str | Path | None = None):
|
|
43
|
+
self._path = Path(persistence_path) if persistence_path else None
|
|
44
|
+
self._lock = threading.RLock()
|
|
45
|
+
# Keyed by f"{capability}::{provider.app}::{provider.device_id}"
|
|
46
|
+
self._records: dict[str, dict[str, Any]] = {}
|
|
47
|
+
if self._path:
|
|
48
|
+
self._load()
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _key(capability: str, app: str, device_id: str) -> str:
|
|
52
|
+
return f"{capability}::{app}::{device_id}"
|
|
53
|
+
|
|
54
|
+
def _load(self) -> None:
|
|
55
|
+
if not self._path or not self._path.is_file():
|
|
56
|
+
return
|
|
57
|
+
try:
|
|
58
|
+
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
59
|
+
if isinstance(data, dict):
|
|
60
|
+
self._records = data
|
|
61
|
+
except Exception:
|
|
62
|
+
self._records = {}
|
|
63
|
+
|
|
64
|
+
def _persist(self) -> None:
|
|
65
|
+
if not self._path:
|
|
66
|
+
return
|
|
67
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
fd, tmp = tempfile.mkstemp(
|
|
69
|
+
dir=str(self._path.parent),
|
|
70
|
+
prefix=".capabilities.",
|
|
71
|
+
suffix=".tmp",
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
75
|
+
json.dump(self._records, fh, indent=2)
|
|
76
|
+
try:
|
|
77
|
+
os.chmod(tmp, 0o600)
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
os.replace(tmp, self._path)
|
|
81
|
+
except Exception:
|
|
82
|
+
try:
|
|
83
|
+
os.unlink(tmp)
|
|
84
|
+
except OSError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
def register(self, manifest: dict[str, Any]) -> dict[str, Any]:
|
|
88
|
+
"""Validate against capability-manifest.v1 and upsert as an active capability."""
|
|
89
|
+
assert_valid_schema(manifest, "capability-manifest.v1")
|
|
90
|
+
cap = manifest["capability"]
|
|
91
|
+
provider = manifest["provider"]
|
|
92
|
+
app = provider["app"]
|
|
93
|
+
device_id = provider["device_id"]
|
|
94
|
+
now = time.time()
|
|
95
|
+
|
|
96
|
+
with self._lock:
|
|
97
|
+
k = self._key(cap, app, device_id)
|
|
98
|
+
prev = self._records.get(k)
|
|
99
|
+
record = {
|
|
100
|
+
"manifest": dict(manifest),
|
|
101
|
+
"capability": cap,
|
|
102
|
+
"app": app,
|
|
103
|
+
"device_id": device_id,
|
|
104
|
+
"action_class": manifest["action_class"],
|
|
105
|
+
"status": "active",
|
|
106
|
+
"unavailable_reason": None,
|
|
107
|
+
"registered_at": prev["registered_at"] if prev else now,
|
|
108
|
+
"updated_at": now,
|
|
109
|
+
}
|
|
110
|
+
self._records[k] = record
|
|
111
|
+
self._persist()
|
|
112
|
+
return dict(record)
|
|
113
|
+
|
|
114
|
+
def register_many(self, manifests: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
115
|
+
return [self.register(m) for m in manifests]
|
|
116
|
+
|
|
117
|
+
def withdraw(
|
|
118
|
+
self,
|
|
119
|
+
capability: str,
|
|
120
|
+
app: str,
|
|
121
|
+
*,
|
|
122
|
+
device_id: str | None = None,
|
|
123
|
+
reason: str | None = None,
|
|
124
|
+
) -> int:
|
|
125
|
+
"""Mark matching capability record(s) withdrawn with a clear reason."""
|
|
126
|
+
now = time.time()
|
|
127
|
+
count = 0
|
|
128
|
+
with self._lock:
|
|
129
|
+
for rec in self._records.values():
|
|
130
|
+
if rec["capability"] != capability or rec["app"] != app:
|
|
131
|
+
continue
|
|
132
|
+
if device_id is not None and rec["device_id"] != device_id:
|
|
133
|
+
continue
|
|
134
|
+
if rec["status"] != "withdrawn":
|
|
135
|
+
rec["status"] = "withdrawn"
|
|
136
|
+
rec["unavailable_reason"] = (
|
|
137
|
+
reason or f"Capability '{capability}' withdrawn by provider '{app}'"
|
|
138
|
+
)
|
|
139
|
+
rec["updated_at"] = now
|
|
140
|
+
count += 1
|
|
141
|
+
if count:
|
|
142
|
+
self._persist()
|
|
143
|
+
return count
|
|
144
|
+
|
|
145
|
+
def withdraw_provider(
|
|
146
|
+
self,
|
|
147
|
+
app: str,
|
|
148
|
+
*,
|
|
149
|
+
device_id: str | None = None,
|
|
150
|
+
reason: str | None = None,
|
|
151
|
+
) -> list[dict[str, Any]]:
|
|
152
|
+
"""Withdraw all active capabilities owned by a deregistering or unhealthy provider."""
|
|
153
|
+
now = time.time()
|
|
154
|
+
affected: list[dict[str, Any]] = []
|
|
155
|
+
with self._lock:
|
|
156
|
+
for rec in self._records.values():
|
|
157
|
+
if rec["app"] != app:
|
|
158
|
+
continue
|
|
159
|
+
if device_id is not None and rec["device_id"] != device_id:
|
|
160
|
+
continue
|
|
161
|
+
if rec["status"] == "active":
|
|
162
|
+
rec["status"] = "withdrawn"
|
|
163
|
+
rec["unavailable_reason"] = (
|
|
164
|
+
reason or f"Provider '{app}' ({rec['device_id']}) is offline or deregistered"
|
|
165
|
+
)
|
|
166
|
+
rec["updated_at"] = now
|
|
167
|
+
affected.append(dict(rec))
|
|
168
|
+
if affected:
|
|
169
|
+
self._persist()
|
|
170
|
+
return affected
|
|
171
|
+
|
|
172
|
+
def list_capabilities(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
action_class: str | None = None,
|
|
176
|
+
app: str | None = None,
|
|
177
|
+
capability: str | None = None,
|
|
178
|
+
active_only: bool = True,
|
|
179
|
+
) -> list[dict[str, Any]]:
|
|
180
|
+
with self._lock:
|
|
181
|
+
out: list[dict[str, Any]] = []
|
|
182
|
+
for rec in self._records.values():
|
|
183
|
+
if active_only and rec["status"] != "active":
|
|
184
|
+
continue
|
|
185
|
+
if action_class and rec["action_class"] != action_class:
|
|
186
|
+
continue
|
|
187
|
+
if app and rec["app"] != app:
|
|
188
|
+
continue
|
|
189
|
+
if capability and rec["capability"] != capability:
|
|
190
|
+
continue
|
|
191
|
+
out.append(dict(rec))
|
|
192
|
+
return sorted(out, key=lambda r: (r["capability"], r["app"], r["device_id"]))
|
|
193
|
+
|
|
194
|
+
def resolve(self, capability: str) -> CapabilityResolution:
|
|
195
|
+
"""Resolve healthy providers for `capability` or explain why it is unavailable."""
|
|
196
|
+
with self._lock:
|
|
197
|
+
matching = [r for r in self._records.values() if r["capability"] == capability]
|
|
198
|
+
if not matching:
|
|
199
|
+
return CapabilityResolution(
|
|
200
|
+
capability=capability,
|
|
201
|
+
available=False,
|
|
202
|
+
providers=[],
|
|
203
|
+
unavailable_reason=f"No installed project has registered capability '{capability}'",
|
|
204
|
+
)
|
|
205
|
+
active = [dict(r) for r in matching if r["status"] == "active"]
|
|
206
|
+
if active:
|
|
207
|
+
return CapabilityResolution(
|
|
208
|
+
capability=capability,
|
|
209
|
+
available=True,
|
|
210
|
+
providers=active,
|
|
211
|
+
unavailable_reason=None,
|
|
212
|
+
)
|
|
213
|
+
reasons = sorted(
|
|
214
|
+
{
|
|
215
|
+
r.get("unavailable_reason") or f"Provider '{r['app']}' unavailable"
|
|
216
|
+
for r in matching
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
return CapabilityResolution(
|
|
220
|
+
capability=capability,
|
|
221
|
+
available=False,
|
|
222
|
+
providers=[],
|
|
223
|
+
unavailable_reason="; ".join(reasons),
|
|
224
|
+
)
|
|
225
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Cross-platform single-host advisory lock preventing duplicate local Hub instances."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def default_hub_lock_path() -> Path:
|
|
13
|
+
override = (os.environ.get("ECOSYSTEM_HUB_LOCK_FILE") or "").strip()
|
|
14
|
+
if override:
|
|
15
|
+
return Path(override)
|
|
16
|
+
if sys.platform == "win32":
|
|
17
|
+
base = Path(os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData" / "Local"))
|
|
18
|
+
return base / "Ecosystem" / "hub-host.lock"
|
|
19
|
+
return Path.home() / ".local" / "share" / "ecosystem" / "hub-host.lock"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _try_flock(fd: int) -> bool:
|
|
23
|
+
if sys.platform == "win32":
|
|
24
|
+
import msvcrt
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
28
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
29
|
+
return True
|
|
30
|
+
except OSError:
|
|
31
|
+
return False
|
|
32
|
+
else:
|
|
33
|
+
import fcntl
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
37
|
+
return True
|
|
38
|
+
except OSError:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _unlock_fd(fd: int) -> None:
|
|
43
|
+
if sys.platform == "win32":
|
|
44
|
+
import msvcrt
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
48
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
|
51
|
+
else:
|
|
52
|
+
import fcntl
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SingleHostLock:
|
|
61
|
+
"""Process-bound advisory file lock ensuring at most one embedded Hub per machine."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, path: str | Path | None = None):
|
|
64
|
+
self.path = Path(path) if path is not None else default_hub_lock_path()
|
|
65
|
+
self._fd: int | None = None
|
|
66
|
+
self._metadata: dict[str, Any] | None = None
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_held(self) -> bool:
|
|
70
|
+
return self._fd is not None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def metadata(self) -> dict[str, Any] | None:
|
|
74
|
+
return dict(self._metadata) if self._metadata else None
|
|
75
|
+
|
|
76
|
+
def acquire(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
app_name: str,
|
|
80
|
+
hub_url: str,
|
|
81
|
+
priority: int = 0,
|
|
82
|
+
pid: int | None = None,
|
|
83
|
+
) -> bool:
|
|
84
|
+
"""Attempt to acquire the single-host lock without blocking."""
|
|
85
|
+
if self._fd is not None:
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
flags = os.O_CREAT | os.O_RDWR
|
|
90
|
+
fd = os.open(str(self.path), flags, 0o600)
|
|
91
|
+
if not _try_flock(fd):
|
|
92
|
+
os.close(fd)
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
meta = {
|
|
96
|
+
"app_name": app_name,
|
|
97
|
+
"pid": os.getpid() if pid is None else int(pid),
|
|
98
|
+
"priority": int(priority),
|
|
99
|
+
"hub_url": hub_url,
|
|
100
|
+
"acquired_at": time.time(),
|
|
101
|
+
}
|
|
102
|
+
payload = json.dumps(meta, indent=2).encode("utf-8")
|
|
103
|
+
try:
|
|
104
|
+
os.ftruncate(fd, 0)
|
|
105
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
106
|
+
os.write(fd, payload)
|
|
107
|
+
os.fsync(fd)
|
|
108
|
+
except OSError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
self._fd = fd
|
|
112
|
+
self._metadata = meta
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
def release(self) -> None:
|
|
116
|
+
"""Release the advisory lock if currently held by this instance."""
|
|
117
|
+
if self._fd is None:
|
|
118
|
+
return
|
|
119
|
+
fd = self._fd
|
|
120
|
+
self._fd = None
|
|
121
|
+
self._metadata = None
|
|
122
|
+
try:
|
|
123
|
+
os.ftruncate(fd, 0)
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
126
|
+
_unlock_fd(fd)
|
|
127
|
+
try:
|
|
128
|
+
os.close(fd)
|
|
129
|
+
except OSError:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
def inspect_owner(self) -> dict[str, Any] | None:
|
|
133
|
+
"""Read current lock owner metadata without blocking (or None if unlocked/empty)."""
|
|
134
|
+
if self._metadata is not None:
|
|
135
|
+
return dict(self._metadata)
|
|
136
|
+
try:
|
|
137
|
+
if not self.path.is_file():
|
|
138
|
+
return None
|
|
139
|
+
raw = self.path.read_text(encoding="utf-8").strip()
|
|
140
|
+
if not raw:
|
|
141
|
+
return None
|
|
142
|
+
parsed = json.loads(raw)
|
|
143
|
+
return parsed if isinstance(parsed, dict) else None
|
|
144
|
+
except Exception:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
def __enter__(self) -> SingleHostLock:
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
151
|
+
self.release()
|
|
152
|
+
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""SQLite-backed offline notification & deferred payload outbox for standalone/disconnected nodes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Awaitable, Callable
|
|
12
|
+
|
|
13
|
+
from ecosystem_client.contracts import assert_valid_schema
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def default_outbox_path(app_name: str) -> Path:
|
|
17
|
+
safe = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in app_name) or "app"
|
|
18
|
+
override = (os.environ.get("ECOSYSTEM_OUTBOX_DIR") or "").strip()
|
|
19
|
+
if override:
|
|
20
|
+
return Path(override) / f"outbox-{safe}.db"
|
|
21
|
+
if sys.platform == "win32":
|
|
22
|
+
base = Path(os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData" / "Local"))
|
|
23
|
+
return base / "Ecosystem" / f"outbox-{safe}.db"
|
|
24
|
+
return Path.home() / ".local" / "share" / "ecosystem" / f"outbox-{safe}.db"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DurableOutbox:
|
|
28
|
+
"""Bounded SQLite WAL queue storing pending `notify.*` envelopes while the Hub is unreachable."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, db_path: str | Path, *, max_items: int = 1000):
|
|
31
|
+
self.db_path = Path(db_path)
|
|
32
|
+
self.max_items = max(10, int(max_items))
|
|
33
|
+
self._lock = threading.RLock()
|
|
34
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
self._init_db()
|
|
36
|
+
|
|
37
|
+
def _connect(self) -> sqlite3.Connection:
|
|
38
|
+
conn = sqlite3.connect(str(self.db_path), timeout=5.0)
|
|
39
|
+
conn.row_factory = sqlite3.Row
|
|
40
|
+
return conn
|
|
41
|
+
|
|
42
|
+
def _init_db(self) -> None:
|
|
43
|
+
with self._lock, self._connect() as conn:
|
|
44
|
+
conn.execute("PRAGMA journal_mode=WAL;")
|
|
45
|
+
conn.execute(
|
|
46
|
+
"""
|
|
47
|
+
CREATE TABLE IF NOT EXISTS notification_outbox (
|
|
48
|
+
id TEXT PRIMARY KEY,
|
|
49
|
+
dedupe_key TEXT NOT NULL,
|
|
50
|
+
envelope_json TEXT NOT NULL,
|
|
51
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
52
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
53
|
+
last_error TEXT,
|
|
54
|
+
created_at REAL NOT NULL,
|
|
55
|
+
updated_at REAL NOT NULL
|
|
56
|
+
);
|
|
57
|
+
"""
|
|
58
|
+
)
|
|
59
|
+
conn.execute(
|
|
60
|
+
"""
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_outbox_status_created
|
|
62
|
+
ON notification_outbox(status, created_at);
|
|
63
|
+
"""
|
|
64
|
+
)
|
|
65
|
+
conn.execute(
|
|
66
|
+
"""
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_outbox_dedupe_pending
|
|
68
|
+
ON notification_outbox(dedupe_key, status);
|
|
69
|
+
"""
|
|
70
|
+
)
|
|
71
|
+
try:
|
|
72
|
+
os.chmod(self.db_path, 0o600)
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
def enqueue(self, envelope: dict[str, Any]) -> bool:
|
|
77
|
+
"""Enqueue a NotificationEnvelope if no pending item shares its dedupe_key.
|
|
78
|
+
|
|
79
|
+
Returns True if newly enqueued, False if deduplicated against a pending item.
|
|
80
|
+
"""
|
|
81
|
+
assert_valid_schema(envelope, "notification-envelope.v1")
|
|
82
|
+
nid = envelope["id"]
|
|
83
|
+
dedupe_key = envelope["dedupe_key"]
|
|
84
|
+
now = time.time()
|
|
85
|
+
payload = json.dumps(envelope, separators=(",", ":"), sort_keys=True)
|
|
86
|
+
|
|
87
|
+
with self._lock, self._connect() as conn:
|
|
88
|
+
existing = conn.execute(
|
|
89
|
+
"SELECT id FROM notification_outbox WHERE dedupe_key = ? AND status = 'pending' LIMIT 1",
|
|
90
|
+
(dedupe_key,),
|
|
91
|
+
).fetchone()
|
|
92
|
+
if existing is not None:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
conn.execute(
|
|
96
|
+
"""
|
|
97
|
+
INSERT OR REPLACE INTO notification_outbox
|
|
98
|
+
(id, dedupe_key, envelope_json, status, attempts, last_error, created_at, updated_at)
|
|
99
|
+
VALUES (?, ?, ?, 'pending', 0, NULL, ?, ?)
|
|
100
|
+
""",
|
|
101
|
+
(nid, dedupe_key, payload, now, now),
|
|
102
|
+
)
|
|
103
|
+
self._prune(conn)
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
def _prune(self, conn: sqlite3.Connection) -> None:
|
|
107
|
+
total_row = conn.execute("SELECT COUNT(*) AS c FROM notification_outbox").fetchone()
|
|
108
|
+
total = int(total_row["c"]) if total_row else 0
|
|
109
|
+
if total <= self.max_items:
|
|
110
|
+
return
|
|
111
|
+
excess = total - self.max_items
|
|
112
|
+
# Evict delivered items first, then oldest pending items to strictly cap disk usage.
|
|
113
|
+
conn.execute(
|
|
114
|
+
"""
|
|
115
|
+
DELETE FROM notification_outbox
|
|
116
|
+
WHERE id IN (
|
|
117
|
+
SELECT id FROM notification_outbox
|
|
118
|
+
ORDER BY CASE WHEN status = 'delivered' THEN 0 ELSE 1 END, created_at ASC
|
|
119
|
+
LIMIT ?
|
|
120
|
+
)
|
|
121
|
+
""",
|
|
122
|
+
(excess,),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def pending(self, limit: int = 50) -> list[dict[str, Any]]:
|
|
126
|
+
with self._lock, self._connect() as conn:
|
|
127
|
+
rows = conn.execute(
|
|
128
|
+
"""
|
|
129
|
+
SELECT envelope_json FROM notification_outbox
|
|
130
|
+
WHERE status = 'pending'
|
|
131
|
+
ORDER BY created_at ASC
|
|
132
|
+
LIMIT ?
|
|
133
|
+
""",
|
|
134
|
+
(int(limit),),
|
|
135
|
+
).fetchall()
|
|
136
|
+
return [json.loads(r["envelope_json"]) for r in rows]
|
|
137
|
+
|
|
138
|
+
def pending_count(self) -> int:
|
|
139
|
+
with self._lock, self._connect() as conn:
|
|
140
|
+
row = conn.execute(
|
|
141
|
+
"SELECT COUNT(*) AS c FROM notification_outbox WHERE status = 'pending'"
|
|
142
|
+
).fetchone()
|
|
143
|
+
return int(row["c"]) if row else 0
|
|
144
|
+
|
|
145
|
+
def mark_delivered(self, notification_id: str) -> None:
|
|
146
|
+
now = time.time()
|
|
147
|
+
with self._lock, self._connect() as conn:
|
|
148
|
+
conn.execute(
|
|
149
|
+
"UPDATE notification_outbox SET status = 'delivered', updated_at = ? WHERE id = ?",
|
|
150
|
+
(now, notification_id),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def record_failure(self, notification_id: str, error: str) -> None:
|
|
154
|
+
now = time.time()
|
|
155
|
+
with self._lock, self._connect() as conn:
|
|
156
|
+
conn.execute(
|
|
157
|
+
"""
|
|
158
|
+
UPDATE notification_outbox
|
|
159
|
+
SET attempts = attempts + 1, last_error = ?, updated_at = ?
|
|
160
|
+
WHERE id = ?
|
|
161
|
+
""",
|
|
162
|
+
(error[:500], now, notification_id),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
async def flush(
|
|
166
|
+
self,
|
|
167
|
+
deliver_fn: Callable[[dict[str, Any]], Awaitable[bool]],
|
|
168
|
+
*,
|
|
169
|
+
batch_size: int = 50,
|
|
170
|
+
) -> dict[str, int]:
|
|
171
|
+
"""Deliver pending notifications via `deliver_fn`. Stops early on transport failure."""
|
|
172
|
+
items = self.pending(limit=batch_size)
|
|
173
|
+
delivered = 0
|
|
174
|
+
failed = 0
|
|
175
|
+
for env in items:
|
|
176
|
+
nid = env["id"]
|
|
177
|
+
try:
|
|
178
|
+
ok = await deliver_fn(env)
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
self.record_failure(nid, repr(exc))
|
|
181
|
+
failed += 1
|
|
182
|
+
break
|
|
183
|
+
if ok:
|
|
184
|
+
self.mark_delivered(nid)
|
|
185
|
+
delivered += 1
|
|
186
|
+
else:
|
|
187
|
+
self.record_failure(nid, "delivery rejected or hub unreachable")
|
|
188
|
+
failed += 1
|
|
189
|
+
break
|
|
190
|
+
return {"delivered": delivered, "failed": failed, "remaining": self.pending_count()}
|
|
191
|
+
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""Embeddable HubRuntime supporting `host | join | auto | off` across ecosystem projects."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ecosystem_auth.tokens import sign_request
|
|
17
|
+
from ecosystem_client import EcosystemClient
|
|
18
|
+
from ecosystem_client.config import EcosystemConfig
|
|
19
|
+
from ecosystem_client.contracts import build_notification_envelope
|
|
20
|
+
from ecosystem_client.ulid import resolve_device_id
|
|
21
|
+
|
|
22
|
+
from .capabilities import CapabilityRegistry, CapabilityResolution
|
|
23
|
+
from .lock import SingleHostLock
|
|
24
|
+
from .outbox import DurableOutbox, default_outbox_path
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HubRole(str, Enum):
|
|
30
|
+
HOST = "host"
|
|
31
|
+
JOIN = "join"
|
|
32
|
+
AUTO = "auto"
|
|
33
|
+
OFF = "off"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_role(value: str | HubRole | None) -> HubRole:
|
|
37
|
+
raw = (value.value if isinstance(value, HubRole) else (value or os.environ.get("ECOSYSTEM_HUB_ROLE") or "auto")).strip().lower()
|
|
38
|
+
for member in HubRole:
|
|
39
|
+
if member.value == raw:
|
|
40
|
+
return member
|
|
41
|
+
logger.warning("Unknown ECOSYSTEM_HUB_ROLE %r — defaulting to 'auto'", raw)
|
|
42
|
+
return HubRole.AUTO
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HubRuntime:
|
|
46
|
+
"""Lifecycle coordinator enabling any eligible app to host, join, or run standalone.
|
|
47
|
+
|
|
48
|
+
Guarantees:
|
|
49
|
+
- Single-host lock (`SingleHostLock`) prevents duplicate embedded Hubs on one device.
|
|
50
|
+
- Hardware-tier awareness declines auto-hosting on `T0_MINIMAL` (<4 GB RAM) devices
|
|
51
|
+
unless `role='host'` or `allow_minimal_host=True` is explicitly configured.
|
|
52
|
+
- Capabilities work locally when standalone and sync to `/capabilities/register` when joined.
|
|
53
|
+
- `notify()` delivers to `/notifications/publish` when connected or buffers in a
|
|
54
|
+
SQLite `DurableOutbox` when offline, flushing automatically on reconnection.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
app_name: str,
|
|
60
|
+
*,
|
|
61
|
+
service_port: int = 0,
|
|
62
|
+
priority: int = 0,
|
|
63
|
+
role: str | HubRole | None = None,
|
|
64
|
+
capabilities: list[dict[str, Any]] | None = None,
|
|
65
|
+
subscriptions: list[str] | None = None,
|
|
66
|
+
health_endpoint: str = "/health",
|
|
67
|
+
config: EcosystemConfig | None = None,
|
|
68
|
+
lock_path: str | Path | None = None,
|
|
69
|
+
outbox_path: str | Path | None = None,
|
|
70
|
+
allow_minimal_host: bool = False,
|
|
71
|
+
sync_interval: float = 20.0,
|
|
72
|
+
host_spawner: Callable[[str, int], subprocess.Popen[Any]] | None = None,
|
|
73
|
+
):
|
|
74
|
+
self.app_name = app_name
|
|
75
|
+
self.service_port = int(service_port)
|
|
76
|
+
self.priority = int(priority)
|
|
77
|
+
self.role = _parse_role(role)
|
|
78
|
+
self.health_endpoint = health_endpoint
|
|
79
|
+
self.device_id = resolve_device_id()
|
|
80
|
+
|
|
81
|
+
self.config = config or EcosystemConfig.from_env()
|
|
82
|
+
if priority:
|
|
83
|
+
self.config.priority = priority
|
|
84
|
+
|
|
85
|
+
self.local_capabilities = CapabilityRegistry()
|
|
86
|
+
self._initial_capabilities = list(capabilities or [])
|
|
87
|
+
self._subscriptions = list(subscriptions or [])
|
|
88
|
+
self._lock = SingleHostLock(lock_path)
|
|
89
|
+
self.outbox = DurableOutbox(outbox_path or default_outbox_path(app_name))
|
|
90
|
+
self._allow_minimal_host = allow_minimal_host
|
|
91
|
+
self._sync_interval = max(1.0, float(sync_interval))
|
|
92
|
+
self._host_spawner = host_spawner
|
|
93
|
+
|
|
94
|
+
self._client: EcosystemClient | None = None
|
|
95
|
+
self._sidecar_proc: subprocess.Popen[Any] | None = None
|
|
96
|
+
self._hosting = False
|
|
97
|
+
self._joined = False
|
|
98
|
+
self._effective_mode = "standalone"
|
|
99
|
+
self._started = False
|
|
100
|
+
self._bg_task: asyncio.Task[None] | None = None
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def effective_mode(self) -> str:
|
|
104
|
+
"""One of `'hosting'`, `'joined'`, or `'standalone'`."""
|
|
105
|
+
return self._effective_mode
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def is_hosting(self) -> bool:
|
|
109
|
+
return self._hosting
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def is_joined(self) -> bool:
|
|
113
|
+
return self._joined
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def client(self) -> EcosystemClient | None:
|
|
117
|
+
return self._client
|
|
118
|
+
|
|
119
|
+
def _normalize_manifest(self, partial: dict[str, Any]) -> dict[str, Any]:
|
|
120
|
+
"""Fill default provider `{app, device_id}` and `schema_version` if omitted."""
|
|
121
|
+
m = dict(partial)
|
|
122
|
+
m.setdefault("schema_version", 1)
|
|
123
|
+
m.setdefault("provider", {"app": self.app_name, "device_id": self.device_id})
|
|
124
|
+
return m
|
|
125
|
+
|
|
126
|
+
def _can_auto_host(self) -> bool:
|
|
127
|
+
if self.priority <= 0:
|
|
128
|
+
return False
|
|
129
|
+
if self._allow_minimal_host:
|
|
130
|
+
return True
|
|
131
|
+
try:
|
|
132
|
+
from ecosystem_ai.hardware import CapabilityTier, probe, tier_for
|
|
133
|
+
|
|
134
|
+
hw = probe()
|
|
135
|
+
if tier_for(hw) == CapabilityTier.T0_MINIMAL:
|
|
136
|
+
logger.info(
|
|
137
|
+
"HubRuntime(%s): Tier T0_MINIMAL (<4 GB RAM) detected; preferring join/standalone over auto-hosting",
|
|
138
|
+
self.app_name,
|
|
139
|
+
)
|
|
140
|
+
return False
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
async def _probe_hub_health(self, timeout: float = 1.5) -> bool:
|
|
146
|
+
url = f"{self.config.registry_url.rstrip('/')}/health"
|
|
147
|
+
try:
|
|
148
|
+
async with httpx.AsyncClient(timeout=timeout) as http:
|
|
149
|
+
resp = await http.get(url)
|
|
150
|
+
return resp.status_code == 200
|
|
151
|
+
except Exception:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
def _default_spawn_sidecar(self, bind_host: str, port: int) -> subprocess.Popen[Any]:
|
|
155
|
+
repo_root = Path(__file__).resolve().parents[3]
|
|
156
|
+
env = os.environ.copy()
|
|
157
|
+
py_paths = [
|
|
158
|
+
str(repo_root),
|
|
159
|
+
str(repo_root / "auth" / "python"),
|
|
160
|
+
str(repo_root / "packages" / "ecosystem-ai"),
|
|
161
|
+
str(repo_root / "packages" / "ecosystem-client"),
|
|
162
|
+
str(repo_root / "packages" / "ecosystem-hub"),
|
|
163
|
+
str(repo_root / "packages" / "ecosystem-policy"),
|
|
164
|
+
]
|
|
165
|
+
existing_py = env.get("PYTHONPATH", "")
|
|
166
|
+
env["PYTHONPATH"] = os.pathsep.join(py_paths + ([existing_py] if existing_py else []))
|
|
167
|
+
cmd = [
|
|
168
|
+
sys.executable,
|
|
169
|
+
"-m",
|
|
170
|
+
"uvicorn",
|
|
171
|
+
"registry.app:app",
|
|
172
|
+
"--host",
|
|
173
|
+
bind_host,
|
|
174
|
+
"--port",
|
|
175
|
+
str(port),
|
|
176
|
+
"--log-level",
|
|
177
|
+
"warning",
|
|
178
|
+
]
|
|
179
|
+
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0
|
|
180
|
+
return subprocess.Popen(
|
|
181
|
+
cmd,
|
|
182
|
+
cwd=str(repo_root),
|
|
183
|
+
env=env,
|
|
184
|
+
stdout=subprocess.DEVNULL,
|
|
185
|
+
stderr=subprocess.DEVNULL,
|
|
186
|
+
creationflags=creationflags,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
async def _try_start_host(self) -> bool:
|
|
190
|
+
parsed = urlparse(self.config.registry_url)
|
|
191
|
+
bind_host = parsed.hostname or "127.0.0.1"
|
|
192
|
+
port = parsed.port or 8500
|
|
193
|
+
|
|
194
|
+
if not self._lock.acquire(
|
|
195
|
+
app_name=self.app_name,
|
|
196
|
+
hub_url=self.config.registry_url,
|
|
197
|
+
priority=self.priority,
|
|
198
|
+
):
|
|
199
|
+
# Another local app holds the lock — wait briefly for its /health to come up.
|
|
200
|
+
for _ in range(10):
|
|
201
|
+
if await self._probe_hub_health(timeout=0.5):
|
|
202
|
+
return False
|
|
203
|
+
await asyncio.sleep(0.25)
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
spawner = self._host_spawner or self._default_spawn_sidecar
|
|
207
|
+
try:
|
|
208
|
+
self._sidecar_proc = spawner(bind_host, port)
|
|
209
|
+
except Exception as exc:
|
|
210
|
+
logger.warning("HubRuntime(%s): failed to launch Hub sidecar: %r", self.app_name, exc)
|
|
211
|
+
self._lock.release()
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
for _ in range(25):
|
|
215
|
+
if self._sidecar_proc.poll() is not None:
|
|
216
|
+
break
|
|
217
|
+
if await self._probe_hub_health(timeout=0.5):
|
|
218
|
+
self._hosting = True
|
|
219
|
+
return True
|
|
220
|
+
await asyncio.sleep(0.2)
|
|
221
|
+
|
|
222
|
+
# Failed health startup — clean up sidecar and release lock.
|
|
223
|
+
self._terminate_sidecar()
|
|
224
|
+
self._lock.release()
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
def _terminate_sidecar(self) -> None:
|
|
228
|
+
proc = self._sidecar_proc
|
|
229
|
+
self._sidecar_proc = None
|
|
230
|
+
if proc is None:
|
|
231
|
+
return
|
|
232
|
+
try:
|
|
233
|
+
if proc.poll() is None:
|
|
234
|
+
proc.terminate()
|
|
235
|
+
try:
|
|
236
|
+
proc.wait(timeout=3.0)
|
|
237
|
+
except subprocess.TimeoutExpired:
|
|
238
|
+
proc.kill()
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
def _signed_headers(self, method: str, url: str, body: Any = None) -> dict[str, str]:
|
|
243
|
+
secret = self.config.hmac_secret
|
|
244
|
+
if not secret:
|
|
245
|
+
return {}
|
|
246
|
+
return sign_request(method, url, secret, body=body)
|
|
247
|
+
|
|
248
|
+
async def _sync_capabilities_to_hub(self) -> bool:
|
|
249
|
+
manifests = [
|
|
250
|
+
rec["manifest"]
|
|
251
|
+
for rec in self.local_capabilities.list_capabilities(app=self.app_name, active_only=True)
|
|
252
|
+
]
|
|
253
|
+
if not manifests:
|
|
254
|
+
return True
|
|
255
|
+
url = f"{self.config.registry_url.rstrip('/')}/capabilities/register"
|
|
256
|
+
body = {"capabilities": manifests}
|
|
257
|
+
headers = self._signed_headers("POST", url, body)
|
|
258
|
+
try:
|
|
259
|
+
async with httpx.AsyncClient(timeout=self.config.request_timeout) as http:
|
|
260
|
+
resp = await http.post(url, json=body, headers=headers)
|
|
261
|
+
return resp.status_code < 400
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
logger.debug("HubRuntime(%s): capability sync deferred: %r", self.app_name, exc)
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
async def _withdraw_capabilities_from_hub(self) -> None:
|
|
267
|
+
url = f"{self.config.registry_url.rstrip('/')}/capabilities/{self.app_name}"
|
|
268
|
+
headers = self._signed_headers("DELETE", url)
|
|
269
|
+
try:
|
|
270
|
+
async with httpx.AsyncClient(timeout=2.5) as http:
|
|
271
|
+
await http.delete(url, headers=headers)
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
async def _deliver_notification_to_hub(self, envelope: dict[str, Any]) -> bool:
|
|
276
|
+
url = f"{self.config.registry_url.rstrip('/')}/notifications/publish"
|
|
277
|
+
headers = self._signed_headers("POST", url, envelope)
|
|
278
|
+
async with httpx.AsyncClient(timeout=self.config.request_timeout) as http:
|
|
279
|
+
resp = await http.post(url, json=envelope, headers=headers)
|
|
280
|
+
return resp.status_code < 400
|
|
281
|
+
|
|
282
|
+
async def _join_reachable_hub(self) -> None:
|
|
283
|
+
if self.service_port > 0 and self._client is None:
|
|
284
|
+
self._client = EcosystemClient(
|
|
285
|
+
service_name=self.app_name,
|
|
286
|
+
service_port=self.service_port,
|
|
287
|
+
health_endpoint=self.health_endpoint,
|
|
288
|
+
subscriptions=self._subscriptions,
|
|
289
|
+
config=self.config,
|
|
290
|
+
priority=self.priority,
|
|
291
|
+
)
|
|
292
|
+
await self._client.start()
|
|
293
|
+
|
|
294
|
+
self._joined = True
|
|
295
|
+
self._effective_mode = "hosting" if self._hosting else "joined"
|
|
296
|
+
await self._sync_capabilities_to_hub()
|
|
297
|
+
await self.outbox.flush(self._deliver_notification_to_hub)
|
|
298
|
+
|
|
299
|
+
async def start(self) -> str:
|
|
300
|
+
"""Start the HubRuntime according to `role`, returning `effective_mode`."""
|
|
301
|
+
if self._started:
|
|
302
|
+
return self._effective_mode
|
|
303
|
+
|
|
304
|
+
for raw_cap in self._initial_capabilities:
|
|
305
|
+
self.local_capabilities.register(self._normalize_manifest(raw_cap))
|
|
306
|
+
|
|
307
|
+
self._started = True
|
|
308
|
+
|
|
309
|
+
if self.role == HubRole.OFF:
|
|
310
|
+
self._effective_mode = "standalone"
|
|
311
|
+
return self._effective_mode
|
|
312
|
+
|
|
313
|
+
if await self._probe_hub_health():
|
|
314
|
+
await self._join_reachable_hub()
|
|
315
|
+
elif self.role == HubRole.HOST or (self.role == HubRole.AUTO and self._can_auto_host()):
|
|
316
|
+
hosted = await self._try_start_host()
|
|
317
|
+
if hosted or await self._probe_hub_health():
|
|
318
|
+
await self._join_reachable_hub()
|
|
319
|
+
else:
|
|
320
|
+
self._effective_mode = "standalone"
|
|
321
|
+
else:
|
|
322
|
+
self._effective_mode = "standalone"
|
|
323
|
+
|
|
324
|
+
self._bg_task = asyncio.create_task(self._background_loop())
|
|
325
|
+
return self._effective_mode
|
|
326
|
+
|
|
327
|
+
async def _background_loop(self) -> None:
|
|
328
|
+
while self._started:
|
|
329
|
+
await asyncio.sleep(self._sync_interval)
|
|
330
|
+
try:
|
|
331
|
+
alive = await self._probe_hub_health()
|
|
332
|
+
if alive:
|
|
333
|
+
if not self._joined:
|
|
334
|
+
await self._join_reachable_hub()
|
|
335
|
+
else:
|
|
336
|
+
await self._sync_capabilities_to_hub()
|
|
337
|
+
if self.outbox.pending_count() > 0:
|
|
338
|
+
await self.outbox.flush(self._deliver_notification_to_hub)
|
|
339
|
+
else:
|
|
340
|
+
if self._joined and not self._hosting:
|
|
341
|
+
self._joined = False
|
|
342
|
+
self._effective_mode = "standalone"
|
|
343
|
+
except Exception as exc:
|
|
344
|
+
logger.debug("HubRuntime(%s) background sync error: %r", self.app_name, exc)
|
|
345
|
+
|
|
346
|
+
async def stop(self) -> None:
|
|
347
|
+
"""Orderly shutdown: withdraw capabilities, close client, stop hosted sidecar, release lock."""
|
|
348
|
+
self._started = False
|
|
349
|
+
if self._bg_task:
|
|
350
|
+
self._bg_task.cancel()
|
|
351
|
+
try:
|
|
352
|
+
await self._bg_task
|
|
353
|
+
except asyncio.CancelledError:
|
|
354
|
+
pass
|
|
355
|
+
self._bg_task = None
|
|
356
|
+
|
|
357
|
+
if self._joined and not self._hosting:
|
|
358
|
+
await self._withdraw_capabilities_from_hub()
|
|
359
|
+
|
|
360
|
+
if self._client is not None:
|
|
361
|
+
try:
|
|
362
|
+
await self._client.stop()
|
|
363
|
+
except Exception:
|
|
364
|
+
pass
|
|
365
|
+
self._client = None
|
|
366
|
+
|
|
367
|
+
if self._hosting:
|
|
368
|
+
self._terminate_sidecar()
|
|
369
|
+
self._hosting = False
|
|
370
|
+
|
|
371
|
+
self._lock.release()
|
|
372
|
+
self._joined = False
|
|
373
|
+
self._effective_mode = "standalone"
|
|
374
|
+
|
|
375
|
+
async def register_capability(self, manifest: dict[str, Any]) -> dict[str, Any]:
|
|
376
|
+
norm = self._normalize_manifest(manifest)
|
|
377
|
+
rec = self.local_capabilities.register(norm)
|
|
378
|
+
if self._joined:
|
|
379
|
+
await self._sync_capabilities_to_hub()
|
|
380
|
+
return rec
|
|
381
|
+
|
|
382
|
+
async def withdraw_capability(self, capability: str, *, reason: str | None = None) -> int:
|
|
383
|
+
count = self.local_capabilities.withdraw(
|
|
384
|
+
capability,
|
|
385
|
+
self.app_name,
|
|
386
|
+
device_id=self.device_id,
|
|
387
|
+
reason=reason,
|
|
388
|
+
)
|
|
389
|
+
if self._joined:
|
|
390
|
+
url = f"{self.config.registry_url.rstrip('/')}/capabilities/{self.app_name}/{capability}"
|
|
391
|
+
headers = self._signed_headers("DELETE", url)
|
|
392
|
+
try:
|
|
393
|
+
async with httpx.AsyncClient(timeout=2.5) as http:
|
|
394
|
+
await http.delete(url, headers=headers)
|
|
395
|
+
except Exception:
|
|
396
|
+
pass
|
|
397
|
+
return count
|
|
398
|
+
|
|
399
|
+
def resolve_capability(self, capability: str) -> CapabilityResolution:
|
|
400
|
+
return self.local_capabilities.resolve(capability)
|
|
401
|
+
|
|
402
|
+
async def notify(
|
|
403
|
+
self,
|
|
404
|
+
notify_type: str,
|
|
405
|
+
severity: str,
|
|
406
|
+
title: str,
|
|
407
|
+
body: str,
|
|
408
|
+
*,
|
|
409
|
+
dedupe_key: str,
|
|
410
|
+
actions: list[dict[str, Any]] | None = None,
|
|
411
|
+
privacy: str = "redacted",
|
|
412
|
+
ttl: str = "24h",
|
|
413
|
+
deep_link: str | None = None,
|
|
414
|
+
) -> dict[str, Any]:
|
|
415
|
+
"""Build a signed NotificationEnvelope and deliver to Hub or queue in DurableOutbox."""
|
|
416
|
+
envelope = build_notification_envelope(
|
|
417
|
+
notify_type=notify_type,
|
|
418
|
+
source_app=self.app_name,
|
|
419
|
+
severity=severity,
|
|
420
|
+
title=title,
|
|
421
|
+
body=body,
|
|
422
|
+
dedupe_key=dedupe_key,
|
|
423
|
+
device_id=self.device_id,
|
|
424
|
+
actions=actions,
|
|
425
|
+
privacy=privacy,
|
|
426
|
+
ttl=ttl,
|
|
427
|
+
deep_link=deep_link,
|
|
428
|
+
secret=self.config.hmac_secret or None,
|
|
429
|
+
)
|
|
430
|
+
if self._joined:
|
|
431
|
+
try:
|
|
432
|
+
if await self._deliver_notification_to_hub(envelope):
|
|
433
|
+
return {"status": "delivered", "id": envelope["id"]}
|
|
434
|
+
except Exception:
|
|
435
|
+
pass
|
|
436
|
+
|
|
437
|
+
queued = self.outbox.enqueue(envelope)
|
|
438
|
+
return {
|
|
439
|
+
"status": "queued_offline" if queued else "deduplicated_offline",
|
|
440
|
+
"id": envelope["id"],
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
def status(self) -> dict[str, Any]:
|
|
444
|
+
return {
|
|
445
|
+
"app_name": self.app_name,
|
|
446
|
+
"device_id": self.device_id,
|
|
447
|
+
"role": self.role.value,
|
|
448
|
+
"effective_mode": self._effective_mode,
|
|
449
|
+
"hosting": self._hosting,
|
|
450
|
+
"joined": self._joined,
|
|
451
|
+
"hub_url": self.config.registry_url,
|
|
452
|
+
"lock_owner": self._lock.inspect_owner(),
|
|
453
|
+
"registered_capabilities": [
|
|
454
|
+
r["capability"]
|
|
455
|
+
for r in self.local_capabilities.list_capabilities(app=self.app_name, active_only=True)
|
|
456
|
+
],
|
|
457
|
+
"pending_outbox": self.outbox.pending_count(),
|
|
458
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "appecosystem-hub"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Embedded/sidecar Hub runtime, single-host lock, capability registry, and durable offline outbox for appEcosystem"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Mikel Smart" }]
|
|
14
|
+
keywords = ["appecosystem", "service-mesh", "capabilities", "notifications", "embedded-hub"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"httpx>=0.24.0",
|
|
26
|
+
"appecosystem-auth>=0.4.0",
|
|
27
|
+
# 0.4.0 is the first client release with ecosystem_client.contracts and .ulid.
|
|
28
|
+
"appecosystem-client>=0.4.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest>=7.0", "pytest-asyncio>=0.21"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/M1K31/appEcosystem"
|
|
36
|
+
Source = "https://github.com/M1K31/appEcosystem"
|
|
37
|
+
Issues = "https://github.com/M1K31/appEcosystem/issues"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["."]
|
|
41
|
+
include = ["ecosystem_hub*"]
|
|
42
|
+
|