@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.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +60 -0
  4. package/npm/cli.cjs +289 -0
  5. package/package.json +32 -0
  6. package/server/LICENSE +202 -0
  7. package/server/NOTICE +19 -0
  8. package/server/VERSION +1 -0
  9. package/server/activation_transaction.py +2458 -0
  10. package/server/agent_server.py +93226 -0
  11. package/server/agentsdock_chats.py +1237 -0
  12. package/server/agentsdock_emergency.py +338 -0
  13. package/server/agentsdock_jobs.py +640 -0
  14. package/server/agentsdock_mail.py +325 -0
  15. package/server/agentsdock_publish.py +404 -0
  16. package/server/agentsdock_team.py +793 -0
  17. package/server/agentsdock_team_hub/__init__.py +33 -0
  18. package/server/agentsdock_team_hub/auth.py +710 -0
  19. package/server/agentsdock_team_hub/cli.py +647 -0
  20. package/server/agentsdock_team_hub/database.py +310 -0
  21. package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
  22. package/server/agentsdock_team_hub/mail_hints.py +201 -0
  23. package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
  24. package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
  25. package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
  26. package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
  27. package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
  28. package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
  29. package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
  30. package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
  31. package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
  32. package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
  33. package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
  34. package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
  35. package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
  36. package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
  37. package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
  38. package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
  39. package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
  40. package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
  41. package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
  42. package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
  43. package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
  44. package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
  45. package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
  46. package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
  47. package/server/agentsdock_team_hub/notification_hints.py +306 -0
  48. package/server/agentsdock_team_hub/secure_peer.py +11215 -0
  49. package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
  50. package/server/agentsdock_team_hub/security.py +318 -0
  51. package/server/agentsdock_team_hub/service.py +1737 -0
  52. package/server/agentsdock_team_hub/store.py +16276 -0
  53. package/server/chat_mailbox.py +448 -0
  54. package/server/claude_background_reconciliation.py +92 -0
  55. package/server/claude_goals.py +245 -0
  56. package/server/claude_history_provenance.py +137 -0
  57. package/server/claude_history_repair.py +1601 -0
  58. package/server/claude_sdk_client.py +3731 -0
  59. package/server/claude_side_question.py +212 -0
  60. package/server/codex_app_server.py +3030 -0
  61. package/server/codex_auth.py +93 -0
  62. package/server/codex_history_repair.py +1247 -0
  63. package/server/codex_provider.py +1120 -0
  64. package/server/codex_side_question.py +301 -0
  65. package/server/cursor_agent_client.py +654 -0
  66. package/server/cursor_process_guard.py +347 -0
  67. package/server/execution_activation.py +610 -0
  68. package/server/execution_control.py +233 -0
  69. package/server/execution_durability.py +184 -0
  70. package/server/execution_http.py +256 -0
  71. package/server/execution_install.py +792 -0
  72. package/server/execution_legacy_runner.py +406 -0
  73. package/server/execution_maintenance.py +137 -0
  74. package/server/execution_manage.py +615 -0
  75. package/server/execution_ownership.py +83 -0
  76. package/server/execution_preparation.py +362 -0
  77. package/server/execution_recovery.py +845 -0
  78. package/server/execution_recovery_status.py +195 -0
  79. package/server/execution_service.py +387 -0
  80. package/server/execution_transport.py +622 -0
  81. package/server/execution_uninstall.py +406 -0
  82. package/server/execution_update_status.py +49 -0
  83. package/server/install.sh +7074 -0
  84. package/server/interactive_chat_controls.py +153 -0
  85. package/server/interactive_chat_native.py +66 -0
  86. package/server/interactive_chat_projection.py +152 -0
  87. package/server/interactive_chat_runtime.py +132 -0
  88. package/server/interactive_chat_share_routes.py +534 -0
  89. package/server/interactive_chat_share_web.py +12 -0
  90. package/server/interactive_chat_shares.py +238 -0
  91. package/server/provider_commands.py +425 -0
  92. package/server/public_chat_share_routes.py +426 -0
  93. package/server/public_chat_shares.py +759 -0
  94. package/server/public_chat_transcript.py +357 -0
  95. package/server/pyproject.toml +29 -0
  96. package/server/release-public-key.pem +3 -0
  97. package/server/secure_peer_delivery.py +1236 -0
  98. package/server/secure_peer_runtime.py +7694 -0
  99. package/server/shared_chat_video_stream.py +262 -0
  100. package/server/shared_chat_videos.py +155 -0
  101. package/server/side_questions.py +594 -0
  102. package/server/team_hub_host.py +1354 -0
  103. package/server/team_mail_grants.py +136 -0
  104. package/server/team_mail_runtime.py +302 -0
  105. package/server/team_mail_websocket.py +211 -0
  106. package/server/title_generation.py +333 -0
  107. package/server/uninstall.sh +359 -0
  108. package/server/update_handoff.py +237 -0
  109. package/server/update_preparation.py +264 -0
  110. package/server/update_recovery.py +140 -0
  111. package/server/update_runner.py +1819 -0
  112. package/server/uv.lock +1101 -0
  113. 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)