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.
@@ -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,7 @@
1
+ httpx>=0.24.0
2
+ appecosystem-auth>=0.4.0
3
+ appecosystem-client>=0.4.0
4
+
5
+ [dev]
6
+ pytest>=7.0
7
+ pytest-asyncio>=0.21
@@ -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
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+