@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,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,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
|