@agentsdock/server 1.0.7-beta.5
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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""SQLite connection and append-only migration support for Team Hub."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import hashlib
|
|
7
|
+
from importlib import resources
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import sqlite3
|
|
12
|
+
import stat
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from typing import Iterator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
MIGRATION_NAME = re.compile(r"^(?P<version>[0-9]{4})_(?P<name>[a-z0-9_]+)\.sql$")
|
|
19
|
+
MIGRATION_PACKAGE = "agentsdock_team_hub.migrations"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MigrationError(RuntimeError):
|
|
23
|
+
"""Raised when the on-disk schema and embedded migration chain disagree."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Migration:
|
|
28
|
+
version: int
|
|
29
|
+
name: str
|
|
30
|
+
source: str
|
|
31
|
+
sha256: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _embedded_migrations() -> tuple[Migration, ...]:
|
|
35
|
+
discovered: list[Migration] = []
|
|
36
|
+
root = resources.files(MIGRATION_PACKAGE)
|
|
37
|
+
for entry in root.iterdir():
|
|
38
|
+
match = MIGRATION_NAME.fullmatch(entry.name)
|
|
39
|
+
if match is None:
|
|
40
|
+
continue
|
|
41
|
+
source = entry.read_text(encoding="utf-8")
|
|
42
|
+
discovered.append(
|
|
43
|
+
Migration(
|
|
44
|
+
version=int(match.group("version")),
|
|
45
|
+
name=match.group("name"),
|
|
46
|
+
source=source,
|
|
47
|
+
sha256=hashlib.sha256(source.encode("utf-8")).hexdigest(),
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
discovered.sort(key=lambda item: item.version)
|
|
51
|
+
expected = list(range(1, len(discovered) + 1))
|
|
52
|
+
actual = [item.version for item in discovered]
|
|
53
|
+
if not discovered or actual != expected:
|
|
54
|
+
raise MigrationError(
|
|
55
|
+
f"Team Hub migrations must be contiguous from 0001; found {actual}"
|
|
56
|
+
)
|
|
57
|
+
return tuple(discovered)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
MIGRATIONS = _embedded_migrations()
|
|
61
|
+
LATEST_SCHEMA_VERSION = MIGRATIONS[-1].version
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_INITIALIZATION_LOCKS_GUARD = threading.Lock()
|
|
65
|
+
_INITIALIZATION_LOCKS: dict[str, threading.Lock] = {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _initialization_lock(database: str) -> threading.Lock:
|
|
69
|
+
"""Serialize first-open WAL setup and migration within this process.
|
|
70
|
+
|
|
71
|
+
SQLite's busy timeout does not reliably cover a simultaneous
|
|
72
|
+
``PRAGMA journal_mode=WAL`` on a brand-new database. A process lock avoids
|
|
73
|
+
that common desktop/service race; the bounded retry below also covers a
|
|
74
|
+
second Team Hub process opening the same file at the same time.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
with _INITIALIZATION_LOCKS_GUARD:
|
|
78
|
+
return _INITIALIZATION_LOCKS.setdefault(database, threading.Lock())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _retry_locked(operation, *, timeout_seconds: float = 8.0):
|
|
82
|
+
deadline = time.monotonic() + timeout_seconds
|
|
83
|
+
delay = 0.01
|
|
84
|
+
while True:
|
|
85
|
+
try:
|
|
86
|
+
return operation()
|
|
87
|
+
except sqlite3.OperationalError as exc:
|
|
88
|
+
message = str(exc).lower()
|
|
89
|
+
if "locked" not in message and "busy" not in message:
|
|
90
|
+
raise
|
|
91
|
+
if time.monotonic() >= deadline:
|
|
92
|
+
raise
|
|
93
|
+
time.sleep(delay)
|
|
94
|
+
delay = min(delay * 2, 0.25)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _statements(source: str) -> Iterator[str]:
|
|
98
|
+
"""Split trusted migration SQL without breaking trigger bodies."""
|
|
99
|
+
|
|
100
|
+
pending = ""
|
|
101
|
+
for line in source.splitlines(keepends=True):
|
|
102
|
+
pending += line
|
|
103
|
+
if sqlite3.complete_statement(pending):
|
|
104
|
+
statement = pending.strip()
|
|
105
|
+
pending = ""
|
|
106
|
+
if statement:
|
|
107
|
+
yield statement
|
|
108
|
+
if pending.strip():
|
|
109
|
+
raise MigrationError("migration ends with an incomplete SQL statement")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _configure(connection: sqlite3.Connection) -> None:
|
|
113
|
+
connection.row_factory = sqlite3.Row
|
|
114
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
115
|
+
if int(connection.execute("PRAGMA foreign_keys").fetchone()[0]) != 1:
|
|
116
|
+
raise MigrationError("SQLite foreign-key enforcement could not be enabled")
|
|
117
|
+
connection.execute("PRAGMA busy_timeout = 5000")
|
|
118
|
+
connection.execute("PRAGMA synchronous = FULL")
|
|
119
|
+
connection.execute("PRAGMA secure_delete = ON")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _open_secure_database_file(target: Path) -> int:
|
|
123
|
+
"""Open a regular owner-only file before SQLite can create or mutate it."""
|
|
124
|
+
|
|
125
|
+
if os.name == "nt":
|
|
126
|
+
raise MigrationError(
|
|
127
|
+
"Team Hub database ACL hardening is not implemented for Windows hosts"
|
|
128
|
+
)
|
|
129
|
+
parent = target.parent.lstat()
|
|
130
|
+
if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.getuid():
|
|
131
|
+
raise PermissionError(
|
|
132
|
+
"Team Hub database directory must be an owner-controlled real directory"
|
|
133
|
+
)
|
|
134
|
+
parent_mode = stat.S_IMODE(parent.st_mode)
|
|
135
|
+
if parent_mode & 0o022:
|
|
136
|
+
raise PermissionError("Team Hub database directory must not be group/world writable")
|
|
137
|
+
flags = os.O_RDWR | os.O_CREAT
|
|
138
|
+
flags |= getattr(os, "O_CLOEXEC", 0)
|
|
139
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
140
|
+
descriptor = os.open(target, flags, 0o600)
|
|
141
|
+
try:
|
|
142
|
+
opened = os.fstat(descriptor)
|
|
143
|
+
if not stat.S_ISREG(opened.st_mode):
|
|
144
|
+
raise PermissionError("Team Hub database must be a regular file")
|
|
145
|
+
if opened.st_nlink != 1:
|
|
146
|
+
raise PermissionError("Team Hub database must not be hard-linked")
|
|
147
|
+
os.fchmod(descriptor, 0o600)
|
|
148
|
+
if stat.S_IMODE(os.fstat(descriptor).st_mode) != 0o600:
|
|
149
|
+
raise PermissionError("Team Hub database permissions are not owner-only")
|
|
150
|
+
return descriptor
|
|
151
|
+
except BaseException:
|
|
152
|
+
os.close(descriptor)
|
|
153
|
+
raise
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _has_current_wal_migrations(connection: sqlite3.Connection) -> bool:
|
|
157
|
+
"""Verify an existing WAL schema without contending for its writer lock.
|
|
158
|
+
|
|
159
|
+
Every new connection checks the complete bounded ledger and user_version
|
|
160
|
+
in one read snapshot. No path/inode cache can bypass checksum checks after
|
|
161
|
+
another connection changes the ledger or a database is replaced. Anything
|
|
162
|
+
other than the exact current chain still uses the serialized migration
|
|
163
|
+
path, including its existing error reporting for invalid/newer schemas.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
connection.execute("BEGIN")
|
|
167
|
+
try:
|
|
168
|
+
if str(connection.execute("PRAGMA journal_mode").fetchone()[0]).lower() != "wal":
|
|
169
|
+
return False
|
|
170
|
+
if connection.execute(
|
|
171
|
+
"SELECT 1 FROM sqlite_master "
|
|
172
|
+
"WHERE type = 'table' AND name = 'schema_migrations'"
|
|
173
|
+
).fetchone() is None:
|
|
174
|
+
return False
|
|
175
|
+
applied = [
|
|
176
|
+
tuple(row)
|
|
177
|
+
for row in connection.execute(
|
|
178
|
+
"SELECT version, name, sha256 FROM schema_migrations "
|
|
179
|
+
"ORDER BY version LIMIT ?",
|
|
180
|
+
(len(MIGRATIONS) + 1,),
|
|
181
|
+
)
|
|
182
|
+
]
|
|
183
|
+
user_version = int(connection.execute("PRAGMA user_version").fetchone()[0])
|
|
184
|
+
return user_version == LATEST_SCHEMA_VERSION and applied == [
|
|
185
|
+
(migration.version, migration.name, migration.sha256)
|
|
186
|
+
for migration in MIGRATIONS
|
|
187
|
+
]
|
|
188
|
+
except sqlite3.OperationalError as exc:
|
|
189
|
+
# Another first opener may be changing the journal mode. Let the
|
|
190
|
+
# existing initialization lock and bounded retry handle that race.
|
|
191
|
+
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
|
|
192
|
+
return False
|
|
193
|
+
raise
|
|
194
|
+
finally:
|
|
195
|
+
if connection.in_transaction:
|
|
196
|
+
connection.execute("ROLLBACK")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def open_database(path: str | os.PathLike[str] = ":memory:") -> sqlite3.Connection:
|
|
200
|
+
"""Open a hardened SQLite connection and apply every embedded migration."""
|
|
201
|
+
|
|
202
|
+
database = os.fspath(path)
|
|
203
|
+
secure_descriptor: int | None = None
|
|
204
|
+
if database != ":memory:":
|
|
205
|
+
target = Path(database).expanduser()
|
|
206
|
+
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
207
|
+
secure_descriptor = _open_secure_database_file(target)
|
|
208
|
+
database = str(target)
|
|
209
|
+
try:
|
|
210
|
+
connection = sqlite3.connect(database, isolation_level=None, timeout=5.0)
|
|
211
|
+
if secure_descriptor is not None:
|
|
212
|
+
opened = os.fstat(secure_descriptor)
|
|
213
|
+
linked = os.stat(database, follow_symlinks=False)
|
|
214
|
+
if (opened.st_dev, opened.st_ino) != (linked.st_dev, linked.st_ino):
|
|
215
|
+
connection.close()
|
|
216
|
+
raise PermissionError("Team Hub database path changed while opening")
|
|
217
|
+
os.close(secure_descriptor)
|
|
218
|
+
secure_descriptor = None
|
|
219
|
+
_configure(connection)
|
|
220
|
+
if database == ":memory:":
|
|
221
|
+
apply_migrations(connection)
|
|
222
|
+
elif not _has_current_wal_migrations(connection):
|
|
223
|
+
with _initialization_lock(os.path.realpath(database)):
|
|
224
|
+
_retry_locked(lambda: connection.execute("PRAGMA journal_mode = WAL"))
|
|
225
|
+
_retry_locked(lambda: apply_migrations(connection))
|
|
226
|
+
return connection
|
|
227
|
+
except BaseException:
|
|
228
|
+
if secure_descriptor is not None:
|
|
229
|
+
os.close(secure_descriptor)
|
|
230
|
+
if "connection" in locals():
|
|
231
|
+
connection.close()
|
|
232
|
+
raise
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def apply_migrations(connection: sqlite3.Connection) -> int:
|
|
236
|
+
"""Apply and checksum-verify the immutable migration chain."""
|
|
237
|
+
|
|
238
|
+
if connection.in_transaction:
|
|
239
|
+
raise MigrationError("migrations require an autocommit connection")
|
|
240
|
+
_configure(connection)
|
|
241
|
+
connection.execute(
|
|
242
|
+
"""
|
|
243
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
244
|
+
version INTEGER PRIMARY KEY,
|
|
245
|
+
name TEXT NOT NULL,
|
|
246
|
+
sha256 TEXT NOT NULL CHECK(length(sha256) = 64),
|
|
247
|
+
applied_at INTEGER NOT NULL
|
|
248
|
+
)
|
|
249
|
+
"""
|
|
250
|
+
)
|
|
251
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
252
|
+
try:
|
|
253
|
+
# Read the ledger only after taking the writer lock. A second process
|
|
254
|
+
# opening the same fresh/outdated database then observes the first
|
|
255
|
+
# process's committed migration rather than replaying stale decisions.
|
|
256
|
+
applied = {
|
|
257
|
+
int(row["version"]): (str(row["name"]), str(row["sha256"]))
|
|
258
|
+
for row in connection.execute(
|
|
259
|
+
"SELECT version, name, sha256 FROM schema_migrations ORDER BY version"
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
user_version = int(connection.execute("PRAGMA user_version").fetchone()[0])
|
|
263
|
+
applied_version = max(applied, default=0)
|
|
264
|
+
known_versions = {item.version for item in MIGRATIONS}
|
|
265
|
+
unknown_versions = sorted(set(applied).difference(known_versions))
|
|
266
|
+
if unknown_versions or user_version > LATEST_SCHEMA_VERSION:
|
|
267
|
+
raise MigrationError(
|
|
268
|
+
"database schema is newer than this Team Hub build "
|
|
269
|
+
f"(ledger={unknown_versions}, user_version={user_version})"
|
|
270
|
+
)
|
|
271
|
+
if user_version != applied_version:
|
|
272
|
+
raise MigrationError(
|
|
273
|
+
"SQLite user_version does not match the migration ledger "
|
|
274
|
+
f"({user_version} != {applied_version})"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
for migration in MIGRATIONS:
|
|
278
|
+
recorded = applied.get(migration.version)
|
|
279
|
+
if recorded is not None:
|
|
280
|
+
if recorded != (migration.name, migration.sha256):
|
|
281
|
+
raise MigrationError(
|
|
282
|
+
f"migration {migration.version:04d}_{migration.name} checksum changed"
|
|
283
|
+
)
|
|
284
|
+
continue
|
|
285
|
+
if migration.version != applied_version + 1:
|
|
286
|
+
raise MigrationError(
|
|
287
|
+
f"cannot apply migration {migration.version}; "
|
|
288
|
+
f"expected {applied_version + 1}"
|
|
289
|
+
)
|
|
290
|
+
for statement in _statements(migration.source):
|
|
291
|
+
normalized = statement.lstrip().upper()
|
|
292
|
+
if normalized.startswith(("BEGIN", "COMMIT", "ROLLBACK")):
|
|
293
|
+
raise MigrationError(
|
|
294
|
+
f"migration {migration.version} contains transaction control"
|
|
295
|
+
)
|
|
296
|
+
connection.execute(statement)
|
|
297
|
+
connection.execute(
|
|
298
|
+
"""
|
|
299
|
+
INSERT INTO schema_migrations(version, name, sha256, applied_at)
|
|
300
|
+
VALUES (?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))
|
|
301
|
+
""",
|
|
302
|
+
(migration.version, migration.name, migration.sha256),
|
|
303
|
+
)
|
|
304
|
+
connection.execute(f"PRAGMA user_version = {migration.version}")
|
|
305
|
+
applied_version = migration.version
|
|
306
|
+
connection.execute("COMMIT")
|
|
307
|
+
return applied_version
|
|
308
|
+
except BaseException:
|
|
309
|
+
connection.execute("ROLLBACK")
|
|
310
|
+
raise
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Bounded passive Mail stream leases; no sockets, workers, timers or polling.
|
|
2
|
+
|
|
3
|
+
The transport owns its dedicated reader and bounded writer. Authority owners
|
|
4
|
+
close leases at revoke/role/maintenance boundaries, waking idle readers and
|
|
5
|
+
aborting an in-progress write before waiting for that writer to settle.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from contextlib import suppress
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
from .mail_hints import MailArrival, MailHintClosed, MailHintSubscription
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MailHintScopeChanged(MailHintClosed):
|
|
19
|
+
status_code = 403
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MailHintUnsupported(MailHintClosed):
|
|
23
|
+
status_code = 501
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MailHintLease:
|
|
27
|
+
def __init__(
|
|
28
|
+
self, subscription: MailHintSubscription, snapshot: dict[str, Any], *,
|
|
29
|
+
hub_id: str, authorize: Callable[[], None], expires_at: float | None,
|
|
30
|
+
on_close: Callable[[], None] | None = None,
|
|
31
|
+
clock: Callable[[], float] = time.time,
|
|
32
|
+
close_code: Callable[[], int] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
arrival = MailArrival.from_dict(snapshot)
|
|
35
|
+
if type(snapshot.get("reset")) is not bool or arrival.mailbox != subscription.mailbox:
|
|
36
|
+
raise ValueError("Mail stream snapshot is invalid")
|
|
37
|
+
if not isinstance(hub_id, str) or not hub_id or len(hub_id) > 240:
|
|
38
|
+
raise ValueError("Mail stream Hub identity is invalid")
|
|
39
|
+
self.snapshot = dict(snapshot)
|
|
40
|
+
self.hub_id = hub_id
|
|
41
|
+
self.recipient_server_id = arrival.recipient_server_id
|
|
42
|
+
self.expires_at = expires_at
|
|
43
|
+
self._subscription = subscription
|
|
44
|
+
self._authorize = authorize
|
|
45
|
+
self._on_close = on_close
|
|
46
|
+
self._clock = clock
|
|
47
|
+
self._close_code = close_code
|
|
48
|
+
self._latest = arrival
|
|
49
|
+
self._guard = threading.RLock()
|
|
50
|
+
self._writer = threading.RLock()
|
|
51
|
+
self._closed = False
|
|
52
|
+
self._aborter: Callable[[], None] | None = None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def closed(self) -> bool:
|
|
56
|
+
with self._guard:
|
|
57
|
+
return self._closed
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def close_code(self) -> int:
|
|
61
|
+
return self._close_code() if self._close_code is not None else 1012
|
|
62
|
+
|
|
63
|
+
def set_aborter(self, aborter: Callable[[], None]) -> None:
|
|
64
|
+
if not callable(aborter):
|
|
65
|
+
raise ValueError("Mail stream aborter is invalid")
|
|
66
|
+
with self._guard:
|
|
67
|
+
self._aborter = aborter
|
|
68
|
+
closed = self._closed
|
|
69
|
+
if closed:
|
|
70
|
+
with suppress(Exception):
|
|
71
|
+
aborter()
|
|
72
|
+
|
|
73
|
+
def revalidate(self) -> None:
|
|
74
|
+
with self._guard:
|
|
75
|
+
if self._closed or (
|
|
76
|
+
self.expires_at is not None and self._clock() >= self.expires_at
|
|
77
|
+
):
|
|
78
|
+
raise MailHintClosed("Mail stream authority is unavailable")
|
|
79
|
+
# No invocation on an idle wait. The authority owner rechecks current
|
|
80
|
+
# exact claims/binding; the gateway additionally rechecks the mTLS cert.
|
|
81
|
+
self._authorize()
|
|
82
|
+
with self._guard:
|
|
83
|
+
if self._closed:
|
|
84
|
+
raise MailHintClosed("Mail stream authority changed")
|
|
85
|
+
|
|
86
|
+
def take(self, timeout: float | None = None) -> MailArrival | None:
|
|
87
|
+
deadline = None if timeout is None else self._clock() + max(0.0, timeout)
|
|
88
|
+
while not self.closed:
|
|
89
|
+
now = self._clock()
|
|
90
|
+
boundaries = [value for value in (deadline, self.expires_at) if value is not None]
|
|
91
|
+
remaining = max(0.0, min(boundaries) - now) if boundaries else None
|
|
92
|
+
if remaining == 0:
|
|
93
|
+
self.close()
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
arrival = self._subscription.take(timeout=remaining)
|
|
97
|
+
except MailHintClosed:
|
|
98
|
+
self.close()
|
|
99
|
+
return None
|
|
100
|
+
if arrival is None:
|
|
101
|
+
self.close()
|
|
102
|
+
return None
|
|
103
|
+
with self._guard:
|
|
104
|
+
if self._closed:
|
|
105
|
+
return None
|
|
106
|
+
previous = self._latest
|
|
107
|
+
if arrival.mailbox != previous.mailbox or (
|
|
108
|
+
arrival.through_sequence == previous.through_sequence
|
|
109
|
+
and arrival.arrival_id != previous.arrival_id
|
|
110
|
+
):
|
|
111
|
+
invalid = True
|
|
112
|
+
else:
|
|
113
|
+
invalid = False
|
|
114
|
+
if arrival.through_sequence <= previous.through_sequence:
|
|
115
|
+
continue
|
|
116
|
+
self._latest = arrival
|
|
117
|
+
if invalid:
|
|
118
|
+
self.close()
|
|
119
|
+
return None
|
|
120
|
+
return arrival
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def write(self, writer: Callable[[dict[str, Any]], None], cursor: dict[str, Any]) -> None:
|
|
124
|
+
value = MailArrival.from_dict(cursor)
|
|
125
|
+
if value.mailbox != self._subscription.mailbox or type(cursor.get("reset")) is not bool:
|
|
126
|
+
raise ValueError("Mail stream cursor is invalid")
|
|
127
|
+
with self._writer:
|
|
128
|
+
self.revalidate()
|
|
129
|
+
# The transport must bound write time and attach its socket aborter.
|
|
130
|
+
# close() marks closed before aborting, then drains this exact lock.
|
|
131
|
+
writer(dict(cursor))
|
|
132
|
+
|
|
133
|
+
def cancel(self) -> None:
|
|
134
|
+
"""Wake/abort without waiting for a writer (safe on its event loop)."""
|
|
135
|
+
with self._guard:
|
|
136
|
+
if self._closed:
|
|
137
|
+
first = False
|
|
138
|
+
aborter = None
|
|
139
|
+
else:
|
|
140
|
+
first = True
|
|
141
|
+
self._closed = True
|
|
142
|
+
aborter, self._aborter = self._aborter, None
|
|
143
|
+
if first:
|
|
144
|
+
self._subscription.close()
|
|
145
|
+
if aborter is not None:
|
|
146
|
+
with suppress(Exception):
|
|
147
|
+
aborter()
|
|
148
|
+
|
|
149
|
+
def close(self) -> None:
|
|
150
|
+
self.cancel()
|
|
151
|
+
with self._writer:
|
|
152
|
+
pass
|
|
153
|
+
with self._guard:
|
|
154
|
+
on_close, self._on_close = self._on_close, None
|
|
155
|
+
if on_close is not None:
|
|
156
|
+
on_close()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def owned_mail_snapshot(store: Any, claims: Any, team_id: str,
|
|
160
|
+
previous_cursor: dict[str, Any] | None) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
|
161
|
+
"""Derive ownership before handling a retained cursor from an old binding.
|
|
162
|
+
|
|
163
|
+
A changed recipient resets to current owned metadata, never querying the
|
|
164
|
+
old recipient's anchor. A supplied foreign team is still rejected. This is
|
|
165
|
+
transport recovery only; the public store cursor API remains strict.
|
|
166
|
+
"""
|
|
167
|
+
previous = MailArrival.from_dict(previous_cursor) if previous_cursor is not None else None
|
|
168
|
+
if previous is not None and previous.team_id != team_id:
|
|
169
|
+
raise ValueError("Mail stream cursor belongs to another team")
|
|
170
|
+
owned = store.team_mail_arrival_snapshot(claims, team_id)
|
|
171
|
+
retained = previous_cursor
|
|
172
|
+
if previous is not None and previous.recipient_server_id != owned["recipient_server_id"]:
|
|
173
|
+
retained = None
|
|
174
|
+
if retained is not None:
|
|
175
|
+
owned = store.team_mail_arrival_snapshot(claims, team_id, previous_cursor=retained)
|
|
176
|
+
return owned, retained
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Recipient-only Mail arrival metadata; no transport, polling, or authority.
|
|
2
|
+
|
|
3
|
+
Callers must authenticate the exact mailbox before subscribing, then read a
|
|
4
|
+
fresh durable snapshot. A subscription owns one replaceable pending cursor,
|
|
5
|
+
not a queue of messages. Closing wakes its reader; this module starts no
|
|
6
|
+
threads and never calls application callbacks or performs socket/database I/O.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
import re
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MAX_MAIL_SEQUENCE = 9_007_199_254_740_991
|
|
18
|
+
MAIL_IDENTIFIER_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\Z")
|
|
19
|
+
MAIL_ARRIVAL_ID_RE = re.compile(r"tmsg_[0-9a-f]{32}\Z")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _valid_identity(value: Any) -> bool:
|
|
23
|
+
return isinstance(value, str) and MAIL_IDENTIFIER_RE.fullmatch(value) is not None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class MailArrival:
|
|
28
|
+
team_id: str
|
|
29
|
+
recipient_server_id: str
|
|
30
|
+
through_sequence: int
|
|
31
|
+
arrival_id: str | None
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if not _valid_identity(self.team_id) or not _valid_identity(self.recipient_server_id):
|
|
35
|
+
raise ValueError("Mail arrival mailbox identity is invalid")
|
|
36
|
+
if type(self.through_sequence) is not int or not 0 <= self.through_sequence <= MAX_MAIL_SEQUENCE:
|
|
37
|
+
raise ValueError("Mail arrival sequence is invalid")
|
|
38
|
+
if self.through_sequence == 0:
|
|
39
|
+
if self.arrival_id is not None:
|
|
40
|
+
raise ValueError("An empty Mail cursor cannot have an arrival ID")
|
|
41
|
+
elif not isinstance(self.arrival_id, str) or MAIL_ARRIVAL_ID_RE.fullmatch(self.arrival_id) is None:
|
|
42
|
+
raise ValueError("Mail arrival ID is invalid")
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def mailbox(self) -> tuple[str, str]:
|
|
46
|
+
return self.team_id, self.recipient_server_id
|
|
47
|
+
|
|
48
|
+
def as_dict(self, *, reset: bool | None = None) -> dict[str, Any]:
|
|
49
|
+
value: dict[str, Any] = {
|
|
50
|
+
"version": 1,
|
|
51
|
+
"team_id": self.team_id,
|
|
52
|
+
"recipient_server_id": self.recipient_server_id,
|
|
53
|
+
"through_sequence": self.through_sequence,
|
|
54
|
+
"arrival_id": self.arrival_id,
|
|
55
|
+
}
|
|
56
|
+
if reset is not None:
|
|
57
|
+
if type(reset) is not bool:
|
|
58
|
+
raise ValueError("Mail cursor reset is invalid")
|
|
59
|
+
value["reset"] = reset
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_dict(cls, value: Any) -> MailArrival:
|
|
64
|
+
required = {"version", "team_id", "recipient_server_id", "through_sequence", "arrival_id"}
|
|
65
|
+
if (
|
|
66
|
+
not isinstance(value, dict)
|
|
67
|
+
or not required <= value.keys()
|
|
68
|
+
or value.keys() - required - {"reset"}
|
|
69
|
+
or type(value.get("version")) is not int
|
|
70
|
+
or value["version"] != 1
|
|
71
|
+
or ("reset" in value and type(value["reset"]) is not bool)
|
|
72
|
+
):
|
|
73
|
+
raise ValueError("Mail arrival cursor is invalid")
|
|
74
|
+
return cls(value["team_id"], value["recipient_server_id"], value["through_sequence"], value["arrival_id"])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class MailHintClosed(RuntimeError):
|
|
78
|
+
"""The stream must detach; reconnect requires a fresh durable snapshot."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class MailHintCapacity(RuntimeError):
|
|
82
|
+
"""A separate bounded Mail subscriber budget is exhausted."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class MailHintSubscription:
|
|
86
|
+
def __init__(self, broker: MailHintBroker, mailbox: tuple[str, str]) -> None:
|
|
87
|
+
self._broker = broker
|
|
88
|
+
self._changed = threading.Condition(broker._guard)
|
|
89
|
+
self.mailbox = mailbox
|
|
90
|
+
self._pending: MailArrival | None = None
|
|
91
|
+
self._latest: MailArrival | None = None
|
|
92
|
+
self._closed = False
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def closed(self) -> bool:
|
|
96
|
+
with self._broker._guard:
|
|
97
|
+
return self._closed
|
|
98
|
+
|
|
99
|
+
def take(self, timeout: float | None = None) -> MailArrival | None:
|
|
100
|
+
"""Wait for one coalesced hint (or return None on timeout).
|
|
101
|
+
|
|
102
|
+
A future transport owns its reader/writer and cancellation. It must not
|
|
103
|
+
put this lifetime wait in shared interactive HTTP/default-worker slots.
|
|
104
|
+
"""
|
|
105
|
+
with self._changed:
|
|
106
|
+
self._changed.wait_for(
|
|
107
|
+
lambda: self._closed or self._pending is not None, timeout=timeout
|
|
108
|
+
)
|
|
109
|
+
if self._closed:
|
|
110
|
+
raise MailHintClosed("Mail hint subscription is closed")
|
|
111
|
+
pending, self._pending = self._pending, None
|
|
112
|
+
return pending
|
|
113
|
+
|
|
114
|
+
def close(self) -> None:
|
|
115
|
+
self._broker._close_subscription(self)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class MailHintBroker:
|
|
119
|
+
"""Process-local, bounded, nonblocking publication to exact recipients.
|
|
120
|
+
|
|
121
|
+
This is deliberately not an authorization registry or a durable event bus.
|
|
122
|
+
Revoke/role/shutdown owners must close their subscriptions. Failed delivery
|
|
123
|
+
must close the transport so its next subscribe reads durable state again.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, *, max_subscriptions: int = 256, max_per_recipient: int = 4) -> None:
|
|
127
|
+
if type(max_subscriptions) is not int or not 1 <= max_subscriptions <= 4096:
|
|
128
|
+
raise ValueError("Mail subscription limit is invalid")
|
|
129
|
+
if type(max_per_recipient) is not int or not 1 <= max_per_recipient <= 64:
|
|
130
|
+
raise ValueError("Mail recipient subscription limit is invalid")
|
|
131
|
+
self._max_subscriptions = max_subscriptions
|
|
132
|
+
self._max_per_recipient = max_per_recipient
|
|
133
|
+
self._guard = threading.RLock()
|
|
134
|
+
self._subscriptions: dict[tuple[str, str], set[MailHintSubscription]] = {}
|
|
135
|
+
self._count = 0
|
|
136
|
+
self._closed = False
|
|
137
|
+
|
|
138
|
+
def subscribe(self, team_id: str, recipient_server_id: str) -> MailHintSubscription:
|
|
139
|
+
# Identity syntax is not authority. Store/transport must bind claims.
|
|
140
|
+
mailbox = MailArrival(team_id, recipient_server_id, 0, None).mailbox
|
|
141
|
+
with self._guard:
|
|
142
|
+
if self._closed:
|
|
143
|
+
raise MailHintClosed("Mail hint broker is closed")
|
|
144
|
+
current = self._subscriptions.get(mailbox, set())
|
|
145
|
+
if self._count >= self._max_subscriptions or len(current) >= self._max_per_recipient:
|
|
146
|
+
raise MailHintCapacity("Mail hint subscription limit reached")
|
|
147
|
+
subscription = MailHintSubscription(self, mailbox)
|
|
148
|
+
self._subscriptions.setdefault(mailbox, set()).add(subscription)
|
|
149
|
+
self._count += 1
|
|
150
|
+
return subscription
|
|
151
|
+
|
|
152
|
+
def publish(self, arrival: MailArrival) -> None:
|
|
153
|
+
if not isinstance(arrival, MailArrival):
|
|
154
|
+
raise ValueError("Mail hint must be a validated arrival")
|
|
155
|
+
with self._guard:
|
|
156
|
+
if self._closed:
|
|
157
|
+
return
|
|
158
|
+
for subscription in tuple(self._subscriptions.get(arrival.mailbox, ())):
|
|
159
|
+
previous = subscription._latest
|
|
160
|
+
if previous is not None:
|
|
161
|
+
if arrival.through_sequence < previous.through_sequence:
|
|
162
|
+
continue
|
|
163
|
+
if arrival.through_sequence == previous.through_sequence:
|
|
164
|
+
if arrival.arrival_id != previous.arrival_id:
|
|
165
|
+
# Do not choose between incompatible histories.
|
|
166
|
+
self._close_locked(subscription)
|
|
167
|
+
continue
|
|
168
|
+
subscription._latest = arrival
|
|
169
|
+
subscription._pending = arrival
|
|
170
|
+
subscription._changed.notify()
|
|
171
|
+
|
|
172
|
+
def _close_locked(self, subscription: MailHintSubscription) -> None:
|
|
173
|
+
if subscription._closed:
|
|
174
|
+
return
|
|
175
|
+
subscription._closed = True
|
|
176
|
+
subscription._pending = None
|
|
177
|
+
subscription._latest = None
|
|
178
|
+
current = self._subscriptions.get(subscription.mailbox)
|
|
179
|
+
if current is not None:
|
|
180
|
+
current.discard(subscription)
|
|
181
|
+
if not current:
|
|
182
|
+
del self._subscriptions[subscription.mailbox]
|
|
183
|
+
self._count -= 1
|
|
184
|
+
subscription._changed.notify_all()
|
|
185
|
+
|
|
186
|
+
def _close_subscription(self, subscription: MailHintSubscription) -> None:
|
|
187
|
+
with self._guard:
|
|
188
|
+
self._close_locked(subscription)
|
|
189
|
+
|
|
190
|
+
def invalidate(self, team_id: str, recipient_server_id: str) -> None:
|
|
191
|
+
"""Wake and retire exact recipients after a missed publication/revoke."""
|
|
192
|
+
with self._guard:
|
|
193
|
+
for subscription in tuple(self._subscriptions.get((team_id, recipient_server_id), ())):
|
|
194
|
+
self._close_locked(subscription)
|
|
195
|
+
|
|
196
|
+
def close(self) -> None:
|
|
197
|
+
with self._guard:
|
|
198
|
+
self._closed = True
|
|
199
|
+
for current in tuple(self._subscriptions.values()):
|
|
200
|
+
for subscription in tuple(current):
|
|
201
|
+
self._close_locked(subscription)
|