@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,44 @@
1
+ -- A durable existence watermark, not an unread count. Soft deletion, dismissal,
2
+ -- receipts, revisions, and membership changes cannot remove an arrival anchor.
3
+ -- No notification transport or outbox dispatcher is enabled by this migration.
4
+ CREATE TABLE team_mail_arrivals (
5
+ team_id TEXT NOT NULL,
6
+ recipient_node_id TEXT NOT NULL,
7
+ through_sequence INTEGER NOT NULL CHECK(through_sequence > 0),
8
+ arrival_id TEXT NOT NULL,
9
+ PRIMARY KEY(team_id, recipient_node_id),
10
+ FOREIGN KEY(team_id, recipient_node_id) REFERENCES nodes(team_id, id) ON DELETE RESTRICT,
11
+ FOREIGN KEY(team_id, arrival_id) REFERENCES team_messages(team_id, id) ON DELETE RESTRICT
12
+ );
13
+
14
+ -- Reconnect validates one immutable sequence/id/recipient anchor, including
15
+ -- dismissed/deleted copies, without scanning mailbox history or message bodies.
16
+ CREATE INDEX team_mail_server_arrival_lookup
17
+ ON team_message_recipients(team_id, recipient_node_id, message_id)
18
+ WHERE recipient_kind='server';
19
+
20
+ INSERT INTO team_mail_arrivals(team_id, recipient_node_id, through_sequence, arrival_id)
21
+ SELECT latest.team_id, latest.recipient_node_id, latest.through_sequence, m.id
22
+ FROM (
23
+ SELECT r.team_id, r.recipient_node_id, MAX(m.queue_ordinal) AS through_sequence
24
+ FROM team_message_recipients AS r
25
+ JOIN team_messages AS m ON m.team_id=r.team_id AND m.id=r.message_id
26
+ WHERE r.recipient_kind='server' AND m.kind='message'
27
+ GROUP BY r.team_id, r.recipient_node_id
28
+ ) AS latest
29
+ JOIN team_messages AS m ON m.queue_ordinal=latest.through_sequence;
30
+
31
+ -- The exact resolved server copies are created in the same message transaction.
32
+ -- A replay creates no recipients, hence no arrival or notification side effect.
33
+ CREATE TRIGGER team_mail_arrival_on_server_recipient
34
+ AFTER INSERT ON team_message_recipients
35
+ FOR EACH ROW WHEN NEW.recipient_kind='server'
36
+ BEGIN
37
+ INSERT INTO team_mail_arrivals(team_id, recipient_node_id, through_sequence, arrival_id)
38
+ SELECT NEW.team_id, NEW.recipient_node_id, m.queue_ordinal, m.id
39
+ FROM team_messages AS m
40
+ WHERE m.team_id=NEW.team_id AND m.id=NEW.message_id AND m.kind='message'
41
+ ON CONFLICT(team_id, recipient_node_id) DO UPDATE
42
+ SET through_sequence=excluded.through_sequence, arrival_id=excluded.arrival_id
43
+ WHERE excluded.through_sequence > team_mail_arrivals.through_sequence;
44
+ END;
@@ -0,0 +1,4 @@
1
+ -- Exact parent-link traversal must not scan the team's mailbox. Message
2
+ -- identity and parent pointers remain immutable; no data rewrite is needed.
3
+ CREATE INDEX team_messages_parent_order
4
+ ON team_messages(team_id, in_reply_to_message_id, queue_ordinal);
@@ -0,0 +1,49 @@
1
+ -- Metadata-only immutable Bulletin changes. No outbox polling or body copies.
2
+ CREATE TABLE team_bulletin_changes (
3
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
4
+ id TEXT NOT NULL UNIQUE,
5
+ team_id TEXT NOT NULL,
6
+ message_id TEXT NOT NULL,
7
+ change_kind TEXT NOT NULL CHECK(change_kind IN ('created','revised','deleted')),
8
+ message_version INTEGER NOT NULL CHECK(message_version >= 1),
9
+ FOREIGN KEY(team_id,message_id) REFERENCES team_messages(team_id,id) ON DELETE RESTRICT
10
+ );
11
+ CREATE INDEX team_bulletin_changes_by_team ON team_bulletin_changes(team_id,sequence);
12
+
13
+ -- Existing Bulletin establishes a baseline; clients never toast this snapshot.
14
+ INSERT INTO team_bulletin_changes(id,team_id,message_id,change_kind,message_version)
15
+ SELECT 'bchg_' || lower(hex(randomblob(16))), m.team_id, m.id, 'created',
16
+ COALESCE((SELECT MAX(version) FROM team_message_revisions r
17
+ WHERE r.team_id=m.team_id AND r.message_id=m.id),1)
18
+ FROM team_messages m
19
+ WHERE EXISTS (SELECT 1 FROM team_message_recipients r
20
+ WHERE r.team_id=m.team_id AND r.message_id=m.id AND r.recipient_kind='all')
21
+ ORDER BY m.queue_ordinal;
22
+
23
+ CREATE TRIGGER team_bulletin_created AFTER INSERT ON team_message_recipients
24
+ WHEN NEW.recipient_kind='all'
25
+ BEGIN
26
+ INSERT INTO team_bulletin_changes(id,team_id,message_id,change_kind,message_version)
27
+ VALUES ('bchg_' || lower(hex(randomblob(16))),NEW.team_id,NEW.message_id,'created',1);
28
+ END;
29
+ CREATE TRIGGER team_bulletin_revised AFTER INSERT ON team_message_revisions
30
+ WHEN EXISTS (SELECT 1 FROM team_message_recipients r
31
+ WHERE r.team_id=NEW.team_id AND r.message_id=NEW.message_id AND r.recipient_kind='all')
32
+ BEGIN
33
+ INSERT INTO team_bulletin_changes(id,team_id,message_id,change_kind,message_version)
34
+ VALUES ('bchg_' || lower(hex(randomblob(16))),NEW.team_id,NEW.message_id,'revised',NEW.version);
35
+ END;
36
+ CREATE TRIGGER team_bulletin_deleted AFTER INSERT ON network_content_deletions
37
+ WHEN NEW.resource_kind='message' AND EXISTS (
38
+ SELECT 1 FROM team_message_recipients r
39
+ WHERE r.team_id=NEW.team_id AND r.message_id=NEW.resource_id AND r.recipient_kind='all')
40
+ BEGIN
41
+ INSERT INTO team_bulletin_changes(id,team_id,message_id,change_kind,message_version)
42
+ VALUES ('bchg_' || lower(hex(randomblob(16))),NEW.team_id,NEW.resource_id,'deleted',
43
+ COALESCE((SELECT MAX(version) FROM team_message_revisions r
44
+ WHERE r.team_id=NEW.team_id AND r.message_id=NEW.resource_id),1));
45
+ END;
46
+ CREATE TRIGGER team_bulletin_changes_immutable BEFORE UPDATE ON team_bulletin_changes
47
+ BEGIN SELECT RAISE(ABORT,'Bulletin changes are immutable'); END;
48
+ CREATE TRIGGER team_bulletin_changes_retained BEFORE DELETE ON team_bulletin_changes
49
+ BEGIN SELECT RAISE(ABORT,'Bulletin changes are retained'); END;
@@ -0,0 +1,93 @@
1
+ -- Search current Team Messages content, never immutable old revision bodies.
2
+ -- SQLite performs the one-time backfill inside the migration transaction;
3
+ -- no Python body collection or request-time lazy backfill is involved.
4
+ CREATE VIRTUAL TABLE team_message_search USING fts5(
5
+ scope, subject, body,
6
+ tokenize = 'unicode61 remove_diacritics 2', prefix = '2 3 4'
7
+ );
8
+
9
+ INSERT INTO team_message_search(rowid, scope, subject, body)
10
+ SELECT m.queue_ordinal, m.team_id,
11
+ CASE WHEN m.kind='skill' THEN m.title ELSE m.mail_subject END,
12
+ COALESCE((SELECT r.body FROM team_message_revisions AS r
13
+ WHERE r.team_id=m.team_id AND r.message_id=m.id
14
+ ORDER BY r.version DESC LIMIT 1), m.body)
15
+ FROM team_messages AS m
16
+ WHERE NOT EXISTS (SELECT 1 FROM network_content_deletions AS d
17
+ WHERE d.team_id=m.team_id AND d.resource_kind='message'
18
+ AND d.resource_id=m.id);
19
+
20
+ CREATE TRIGGER team_message_search_insert AFTER INSERT ON team_messages
21
+ BEGIN
22
+ INSERT INTO team_message_search(rowid, scope, subject, body)
23
+ VALUES (NEW.queue_ordinal, NEW.team_id,
24
+ CASE WHEN NEW.kind='skill' THEN NEW.title ELSE NEW.mail_subject END,
25
+ NEW.body);
26
+ END;
27
+
28
+ CREATE TRIGGER team_message_search_revision AFTER INSERT ON team_message_revisions
29
+ WHEN NEW.version=(SELECT MAX(r.version) FROM team_message_revisions AS r
30
+ WHERE r.team_id=NEW.team_id AND r.message_id=NEW.message_id)
31
+ BEGIN
32
+ UPDATE team_message_search SET body=NEW.body
33
+ WHERE rowid=(SELECT queue_ordinal FROM team_messages
34
+ WHERE team_id=NEW.team_id AND id=NEW.message_id);
35
+ END;
36
+
37
+ CREATE TRIGGER team_message_search_delete AFTER INSERT ON network_content_deletions
38
+ WHEN NEW.resource_kind='message'
39
+ BEGIN
40
+ DELETE FROM team_message_search
41
+ WHERE rowid=(SELECT queue_ordinal FROM team_messages
42
+ WHERE team_id=NEW.team_id AND id=NEW.resource_id);
43
+ END;
44
+
45
+ -- Names are indexed once per identity, not copied onto every message. Renaming
46
+ -- one server/person stays a small transaction and immediately changes search.
47
+ CREATE VIRTUAL TABLE team_message_sender_nodes USING fts5(
48
+ display_name, content='nodes', content_rowid='rowid',
49
+ tokenize = 'unicode61 remove_diacritics 2', prefix = '2 3 4'
50
+ );
51
+ INSERT INTO team_message_sender_nodes(team_message_sender_nodes) VALUES ('rebuild');
52
+
53
+ CREATE TRIGGER team_message_sender_nodes_insert AFTER INSERT ON nodes
54
+ BEGIN
55
+ INSERT INTO team_message_sender_nodes(rowid, display_name) VALUES (NEW.rowid, NEW.display_name);
56
+ END;
57
+ CREATE TRIGGER team_message_sender_nodes_update AFTER UPDATE OF display_name ON nodes
58
+ BEGIN
59
+ INSERT INTO team_message_sender_nodes(team_message_sender_nodes, rowid, display_name)
60
+ VALUES ('delete', OLD.rowid, OLD.display_name);
61
+ INSERT INTO team_message_sender_nodes(rowid, display_name) VALUES (NEW.rowid, NEW.display_name);
62
+ END;
63
+ CREATE TRIGGER team_message_sender_nodes_delete AFTER DELETE ON nodes
64
+ BEGIN
65
+ INSERT INTO team_message_sender_nodes(team_message_sender_nodes, rowid, display_name)
66
+ VALUES ('delete', OLD.rowid, OLD.display_name);
67
+ END;
68
+
69
+ CREATE VIRTUAL TABLE team_message_sender_principals USING fts5(
70
+ display_name, content='principals', content_rowid='rowid',
71
+ tokenize = 'unicode61 remove_diacritics 2', prefix = '2 3 4'
72
+ );
73
+ INSERT INTO team_message_sender_principals(team_message_sender_principals) VALUES ('rebuild');
74
+
75
+ CREATE TRIGGER team_message_sender_principals_insert AFTER INSERT ON principals
76
+ BEGIN
77
+ INSERT INTO team_message_sender_principals(rowid, display_name) VALUES (NEW.rowid, NEW.display_name);
78
+ END;
79
+ CREATE TRIGGER team_message_sender_principals_update AFTER UPDATE OF display_name ON principals
80
+ BEGIN
81
+ INSERT INTO team_message_sender_principals(team_message_sender_principals, rowid, display_name)
82
+ VALUES ('delete', OLD.rowid, OLD.display_name);
83
+ INSERT INTO team_message_sender_principals(rowid, display_name) VALUES (NEW.rowid, NEW.display_name);
84
+ END;
85
+ CREATE TRIGGER team_message_sender_principals_delete AFTER DELETE ON principals
86
+ BEGIN
87
+ INSERT INTO team_message_sender_principals(team_message_sender_principals, rowid, display_name)
88
+ VALUES ('delete', OLD.rowid, OLD.display_name);
89
+ END;
90
+
91
+ CREATE INDEX team_messages_sender_node_order
92
+ ON team_messages(team_id, sender_node_id, queue_ordinal)
93
+ WHERE sender_kind='server';
@@ -0,0 +1 @@
1
+ """Embedded, append-only Team Hub SQLite migrations."""
@@ -0,0 +1,306 @@
1
+ """Bounded v2 Mail/Bulletin metadata; no polling, content reads, or I/O.
2
+
3
+ Mail keeps its existing recipient-only arrival cursor. Bulletin has a separate
4
+ team-scoped immutable change cursor, so edits never impersonate new mail.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ import re
10
+ import threading
11
+ import time
12
+ from typing import Any
13
+
14
+ from .mail_hints import (
15
+ MAIL_IDENTIFIER_RE, MAIL_ARRIVAL_ID_RE, MAX_MAIL_SEQUENCE,
16
+ MailArrival, MailHintCapacity, MailHintClosed,
17
+ )
18
+ from .mail_hint_streams import MailHintLease
19
+
20
+ BULLETIN_CHANGE_ID_RE = re.compile(r"bchg_[0-9a-f]{32}\Z")
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class BulletinChange:
25
+ team_id: str
26
+ through_sequence: int = 0
27
+ change_id: str | None = None
28
+ message_id: str | None = None
29
+ change_kind: str | None = None
30
+ message_version: int | None = None
31
+
32
+ def __post_init__(self):
33
+ if not isinstance(self.team_id, str) or MAIL_IDENTIFIER_RE.fullmatch(self.team_id) is None:
34
+ raise ValueError("Bulletin team identity is invalid")
35
+ if type(self.through_sequence) is not int or not 0 <= self.through_sequence <= MAX_MAIL_SEQUENCE:
36
+ raise ValueError("Bulletin sequence is invalid")
37
+ if self.through_sequence == 0:
38
+ if any(value is not None for value in (self.change_id, self.message_id, self.change_kind, self.message_version)):
39
+ raise ValueError("Empty Bulletin cursor cannot identify a change")
40
+ elif (
41
+ not isinstance(self.change_id, str) or BULLETIN_CHANGE_ID_RE.fullmatch(self.change_id) is None
42
+ or not isinstance(self.message_id, str) or MAIL_ARRIVAL_ID_RE.fullmatch(self.message_id) is None
43
+ or not isinstance(self.change_kind, str) or self.change_kind not in {"created", "revised", "deleted"}
44
+ or type(self.message_version) is not int or not 1 <= self.message_version <= MAX_MAIL_SEQUENCE
45
+ ):
46
+ raise ValueError("Bulletin change metadata is invalid")
47
+
48
+ def as_dict(self, *, reset: bool | None = None) -> dict[str, Any]:
49
+ result = {"version": 1, "team_id": self.team_id, "through_sequence": self.through_sequence,
50
+ "change_id": self.change_id, "message_id": self.message_id,
51
+ "change_kind": self.change_kind, "message_version": self.message_version}
52
+ if reset is not None:
53
+ if type(reset) is not bool:
54
+ raise ValueError("Bulletin reset is invalid")
55
+ result["reset"] = reset
56
+ return result
57
+
58
+ @classmethod
59
+ def from_dict(cls, value: Any):
60
+ fields = {"version", "team_id", "through_sequence", "change_id", "message_id", "change_kind", "message_version"}
61
+ if (not isinstance(value, dict) or not fields <= value.keys() or value.keys() - fields - {"reset"}
62
+ or type(value["version"]) is not int or value["version"] != 1
63
+ or ("reset" in value and type(value["reset"]) is not bool)):
64
+ raise ValueError("Bulletin cursor is invalid")
65
+ return cls(**{key: value[key] for key in fields - {"version"}})
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class NotificationCursor:
70
+ mail: MailArrival
71
+ bulletin: BulletinChange
72
+ mail_reset: bool = False
73
+ bulletin_reset: bool = False
74
+
75
+ def __post_init__(self):
76
+ if (not isinstance(self.mail, MailArrival) or not isinstance(self.bulletin, BulletinChange)
77
+ or self.mail.team_id != self.bulletin.team_id
78
+ or type(self.mail_reset) is not bool or type(self.bulletin_reset) is not bool):
79
+ raise ValueError("Notification cursor binding is invalid")
80
+
81
+ @property
82
+ def mailbox(self):
83
+ return self.mail.mailbox
84
+
85
+ @property
86
+ def recipient_server_id(self):
87
+ return self.mail.recipient_server_id
88
+
89
+ @property
90
+ def team_id(self):
91
+ return self.mail.team_id
92
+
93
+ def as_dict(self, *, reset: bool | None = None):
94
+ if reset is not None and type(reset) is not bool:
95
+ raise ValueError("Notification reset is invalid")
96
+ return {"version": 2,
97
+ "mail": self.mail.as_dict(reset=self.mail_reset if reset is None else reset),
98
+ "bulletin": self.bulletin.as_dict(reset=self.bulletin_reset if reset is None else reset)}
99
+
100
+ @classmethod
101
+ def from_dict(cls, value: Any):
102
+ if (not isinstance(value, dict) or set(value) != {"version", "mail", "bulletin"}
103
+ or type(value["version"]) is not int or value["version"] != 2):
104
+ raise ValueError("Notification cursor is invalid")
105
+ mail = MailArrival.from_dict(value["mail"])
106
+ bulletin = BulletinChange.from_dict(value["bulletin"])
107
+ if any(type(value[key].get("reset")) is not bool for key in ("mail", "bulletin")):
108
+ raise ValueError("Notification cursors require independent reset flags")
109
+ return cls(mail, bulletin, value["mail"]["reset"], value["bulletin"]["reset"])
110
+
111
+
112
+ class NotificationSubscription:
113
+ def __init__(self, broker, mailbox):
114
+ self._broker, self.mailbox = broker, mailbox
115
+ self._changed = threading.Condition(broker._guard)
116
+ self._mail = self._bulletin = None
117
+ self._seeded = self._pending = self._closed = False
118
+
119
+ @property
120
+ def closed(self):
121
+ with self._broker._guard:
122
+ return self._closed
123
+
124
+ def _publish(self, field, value):
125
+ previous = getattr(self, field)
126
+ if previous is not None:
127
+ if value.through_sequence < previous.through_sequence:
128
+ return
129
+ if value.through_sequence == previous.through_sequence:
130
+ if value != previous:
131
+ self._broker._close_locked(self)
132
+ return
133
+ setattr(self, field, value)
134
+ self._pending = True
135
+ self._changed.notify()
136
+
137
+ def seed(self, cursor: NotificationCursor):
138
+ if not isinstance(cursor, NotificationCursor) or cursor.mailbox != self.mailbox:
139
+ raise ValueError("Notification subscription snapshot changed mailbox")
140
+ with self._changed:
141
+ if self._closed:
142
+ raise MailHintClosed("Notification subscription is closed")
143
+ if self._seeded:
144
+ return
145
+ raced = False
146
+ for field, value in (("_mail", cursor.mail), ("_bulletin", cursor.bulletin)):
147
+ pending = getattr(self, field)
148
+ if pending is not None and pending.through_sequence == value.through_sequence and pending != value:
149
+ self._broker._close_locked(self)
150
+ raise MailHintClosed("Notification snapshot changed history")
151
+ if pending is not None and pending.through_sequence > value.through_sequence:
152
+ raced = True
153
+ else:
154
+ setattr(self, field, value)
155
+ self._seeded, self._pending = True, raced
156
+ self._changed.notify()
157
+
158
+ def take(self, timeout=None):
159
+ with self._changed:
160
+ self._changed.wait_for(lambda: self._closed or (self._seeded and self._pending), timeout)
161
+ if self._closed:
162
+ raise MailHintClosed("Notification subscription is closed")
163
+ if not self._seeded or not self._pending:
164
+ return None
165
+ self._pending = False
166
+ return NotificationCursor(self._mail, self._bulletin)
167
+
168
+ def close(self):
169
+ with self._broker._guard:
170
+ self._broker._close_locked(self)
171
+
172
+
173
+ class NotificationBroker:
174
+ def __init__(self, *, max_subscriptions=256, max_per_recipient=4):
175
+ if (type(max_subscriptions) is not int or not 1 <= max_subscriptions <= 4096
176
+ or type(max_per_recipient) is not int or not 1 <= max_per_recipient <= 64):
177
+ raise ValueError("Notification capacity is invalid")
178
+ self._max_subscriptions, self._max_per_recipient = max_subscriptions, max_per_recipient
179
+ self._guard = threading.RLock()
180
+ self._subscriptions = {}
181
+ self._count = 0
182
+ self._closed = False
183
+
184
+ def subscribe(self, team_id, recipient_server_id):
185
+ mailbox = MailArrival(team_id, recipient_server_id, 0, None).mailbox
186
+ with self._guard:
187
+ if self._closed:
188
+ raise MailHintClosed("Notification broker is closed")
189
+ current = self._subscriptions.get(mailbox, set())
190
+ if self._count >= self._max_subscriptions or len(current) >= self._max_per_recipient:
191
+ raise MailHintCapacity("Notification capacity reached")
192
+ sub = NotificationSubscription(self, mailbox)
193
+ self._subscriptions.setdefault(mailbox, set()).add(sub)
194
+ self._count += 1
195
+ return sub
196
+
197
+ def publish_mail(self, arrival: MailArrival):
198
+ if not isinstance(arrival, MailArrival):
199
+ raise ValueError("Invalid Mail hint")
200
+ with self._guard:
201
+ for sub in tuple(self._subscriptions.get(arrival.mailbox, ())):
202
+ sub._publish("_mail", arrival)
203
+
204
+ def publish_bulletin(self, change: BulletinChange):
205
+ if not isinstance(change, BulletinChange):
206
+ raise ValueError("Invalid Bulletin hint")
207
+ with self._guard:
208
+ for (team_id, _recipient), subscribers in tuple(self._subscriptions.items()):
209
+ if team_id == change.team_id:
210
+ for sub in tuple(subscribers):
211
+ sub._publish("_bulletin", change)
212
+
213
+ def _close_locked(self, sub):
214
+ if sub._closed:
215
+ return
216
+ sub._closed, sub._pending = True, False
217
+ sub._mail = sub._bulletin = None
218
+ current = self._subscriptions.get(sub.mailbox)
219
+ if current is not None:
220
+ current.discard(sub)
221
+ if not current:
222
+ del self._subscriptions[sub.mailbox]
223
+ self._count -= 1
224
+ sub._changed.notify_all()
225
+
226
+ def invalidate(self, team_id, recipient_server_id=None):
227
+ with self._guard:
228
+ for mailbox, subscribers in tuple(self._subscriptions.items()):
229
+ if mailbox[0] == team_id and (recipient_server_id is None or mailbox[1] == recipient_server_id):
230
+ for sub in tuple(subscribers):
231
+ self._close_locked(sub)
232
+
233
+ def close(self):
234
+ with self._guard:
235
+ self._closed = True
236
+ for subscribers in tuple(self._subscriptions.values()):
237
+ for sub in tuple(subscribers):
238
+ self._close_locked(sub)
239
+
240
+
241
+ class NotificationLease(MailHintLease):
242
+ """Use the same cancellation/authorization ownership as v1, with two heads."""
243
+ def __init__(self, subscription, snapshot, *, hub_id, authorize, expires_at,
244
+ on_close=None, clock=time.time, close_code=None):
245
+ cursor = NotificationCursor.from_dict(snapshot)
246
+ if cursor.mailbox != subscription.mailbox or not isinstance(hub_id, str) or not hub_id or len(hub_id) > 240:
247
+ raise ValueError("Notification lease binding is invalid")
248
+ self.snapshot, self.hub_id = cursor.as_dict(), hub_id
249
+ self.recipient_server_id, self.expires_at = cursor.recipient_server_id, expires_at
250
+ self._subscription, self._authorize, self._on_close = subscription, authorize, on_close
251
+ self._clock, self._close_code, self._latest = clock, close_code, cursor
252
+ self._guard, self._writer = threading.RLock(), threading.RLock()
253
+ self._closed, self._aborter = False, None
254
+ subscription.seed(cursor)
255
+
256
+ def take(self, timeout=None):
257
+ deadline = None if timeout is None else self._clock() + max(0.0, timeout)
258
+ while not self.closed:
259
+ boundaries = [value for value in (deadline, self.expires_at) if value is not None]
260
+ remaining = max(0.0, min(boundaries) - self._clock()) if boundaries else None
261
+ if remaining == 0:
262
+ self.close()
263
+ return None
264
+ try:
265
+ cursor = self._subscription.take(timeout=remaining)
266
+ except MailHintClosed:
267
+ self.close()
268
+ return None
269
+ if cursor is None:
270
+ self.close()
271
+ return None
272
+ previous = self._latest
273
+ pairs = ((cursor.mail, previous.mail), (cursor.bulletin, previous.bulletin))
274
+ if cursor.mailbox != previous.mailbox or any(
275
+ current.through_sequence < old.through_sequence
276
+ or (current.through_sequence == old.through_sequence and current != old)
277
+ for current, old in pairs
278
+ ):
279
+ self.close()
280
+ return None
281
+ if all(current == old for current, old in pairs):
282
+ continue
283
+ self._latest = cursor
284
+ return cursor
285
+ return None
286
+
287
+ def write(self, writer, cursor):
288
+ value = NotificationCursor.from_dict(cursor)
289
+ if value.mailbox != self._subscription.mailbox:
290
+ raise ValueError("Notification write changed mailbox")
291
+ with self._writer:
292
+ self.revalidate()
293
+ writer(value.as_dict())
294
+
295
+
296
+ def owned_notification_snapshot(store, claims, team_id, previous_cursor):
297
+ previous = NotificationCursor.from_dict(previous_cursor) if previous_cursor is not None else None
298
+ if previous is not None and previous.team_id != team_id:
299
+ raise ValueError("Notification cursor belongs to another team")
300
+ owned = store.team_notification_snapshot(claims, team_id)
301
+ retained = previous_cursor
302
+ if previous is not None and previous.recipient_server_id != owned["mail"]["recipient_server_id"]:
303
+ retained = None
304
+ if retained is not None:
305
+ owned = store.team_notification_snapshot(claims, team_id, previous_cursor=retained)
306
+ return owned, retained