bazaar-compute-node 0.1.3__py3-none-any.whl

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 (62) hide show
  1. bazaar_compute_node/__init__.py +3 -0
  2. bazaar_compute_node/app/__init__.py +1 -0
  3. bazaar_compute_node/app/application.py +398 -0
  4. bazaar_compute_node/app/attachments.py +154 -0
  5. bazaar_compute_node/app/command.py +342 -0
  6. bazaar_compute_node/app/config.py +121 -0
  7. bazaar_compute_node/app/registry.py +120 -0
  8. bazaar_compute_node/app/transport.py +264 -0
  9. bazaar_compute_node/app/windows_pipe.py +463 -0
  10. bazaar_compute_node/app/wrapper.py +63 -0
  11. bazaar_compute_node/bcc.py +524 -0
  12. bazaar_compute_node/cli.py +382 -0
  13. bazaar_compute_node/contrib/__init__.py +1 -0
  14. bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
  15. bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
  16. bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
  17. bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
  18. bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
  19. bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
  20. bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
  21. bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
  22. bazaar_compute_node/contrib/logging/__init__.py +5 -0
  23. bazaar_compute_node/contrib/logging/audit.py +61 -0
  24. bazaar_compute_node/contrib/logging/plugin.py +11 -0
  25. bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
  26. bazaar_compute_node/contrib/sqlite/codec.py +768 -0
  27. bazaar_compute_node/contrib/sqlite/database.py +282 -0
  28. bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
  29. bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
  30. bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
  31. bazaar_compute_node/contrib/wecom/__init__.py +1 -0
  32. bazaar_compute_node/contrib/wecom/channel.py +960 -0
  33. bazaar_compute_node/contrib/wecom/markdown.py +146 -0
  34. bazaar_compute_node/contrib/wecom/plugin.py +29 -0
  35. bazaar_compute_node/core/__init__.py +5 -0
  36. bazaar_compute_node/core/approval.py +51 -0
  37. bazaar_compute_node/core/audit.py +101 -0
  38. bazaar_compute_node/core/channel.py +121 -0
  39. bazaar_compute_node/core/client.py +30 -0
  40. bazaar_compute_node/core/command.py +85 -0
  41. bazaar_compute_node/core/concurrency.py +29 -0
  42. bazaar_compute_node/core/correlation.py +48 -0
  43. bazaar_compute_node/core/instruction.py +224 -0
  44. bazaar_compute_node/core/lifecycle.py +48 -0
  45. bazaar_compute_node/core/models/__init__.py +63 -0
  46. bazaar_compute_node/core/models/entities.py +514 -0
  47. bazaar_compute_node/core/models/states.py +369 -0
  48. bazaar_compute_node/core/observability.py +47 -0
  49. bazaar_compute_node/core/orchestration/__init__.py +5 -0
  50. bazaar_compute_node/core/orchestration/command.py +614 -0
  51. bazaar_compute_node/core/orchestration/services.py +135 -0
  52. bazaar_compute_node/core/orchestration/session.py +891 -0
  53. bazaar_compute_node/core/orchestration/turn.py +451 -0
  54. bazaar_compute_node/core/outcomes.py +51 -0
  55. bazaar_compute_node/core/paths.py +19 -0
  56. bazaar_compute_node/core/runtime.py +118 -0
  57. bazaar_compute_node/core/storage.py +167 -0
  58. bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
  59. bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
  60. bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
  61. bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
  62. bazaar_compute_node-0.1.3.dist-info/licenses/LICENSE +613 -0
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class _Fence:
8
+ marker: str
9
+ opening: str
10
+
11
+
12
+ def split_markdown(content: str, *, limit: int) -> tuple[str, ...]:
13
+ if limit <= 0:
14
+ raise ValueError("limit must be positive")
15
+ if not content:
16
+ return (content,)
17
+
18
+ chunks: list[str] = []
19
+ cursor = 0
20
+ fence: _Fence | None = None
21
+ while cursor < len(content):
22
+ prefix = f"{fence.opening}\n" if fence is not None else ""
23
+ prefix_bytes = len(prefix.encode("utf-8"))
24
+ end = cursor
25
+ size = prefix_bytes
26
+ while end < len(content):
27
+ encoded = content[end].encode("utf-8")
28
+ if size + len(encoded) > limit:
29
+ break
30
+ size += len(encoded)
31
+ end += 1
32
+ if end == cursor:
33
+ raise ValueError("markdown fence overhead exceeds the provider byte limit")
34
+
35
+ while True:
36
+ next_fence = _advance_fence(
37
+ fence,
38
+ content[cursor:end],
39
+ initial_line_boundary=(cursor == 0 or content[cursor - 1] in "\r\n"),
40
+ terminal_line_complete=(end == len(content) or content[end] in "\r\n"),
41
+ )
42
+ suffix = _closing_suffix(prefix + content[cursor:end], next_fence)
43
+ if len((prefix + content[cursor:end] + suffix).encode("utf-8")) <= limit:
44
+ break
45
+ end -= 1
46
+ if end == cursor:
47
+ raise ValueError(
48
+ "markdown fence closure exceeds the provider byte limit"
49
+ )
50
+
51
+ if end < len(content):
52
+ minimum = cursor + max(1, (end - cursor) // 2)
53
+ preferred = _preferred_boundary(content, cursor, end, minimum)
54
+ if preferred is not None:
55
+ preferred_fence = _advance_fence(
56
+ fence,
57
+ content[cursor:preferred],
58
+ initial_line_boundary=(
59
+ cursor == 0 or content[cursor - 1] in "\r\n"
60
+ ),
61
+ terminal_line_complete=(
62
+ preferred == len(content) or content[preferred] in "\r\n"
63
+ ),
64
+ )
65
+ preferred_suffix = _closing_suffix(
66
+ prefix + content[cursor:preferred], preferred_fence
67
+ )
68
+ if (
69
+ len(
70
+ (prefix + content[cursor:preferred] + preferred_suffix).encode(
71
+ "utf-8"
72
+ )
73
+ )
74
+ <= limit
75
+ ):
76
+ end = preferred
77
+ next_fence = preferred_fence
78
+ suffix = preferred_suffix
79
+
80
+ chunks.append(prefix + content[cursor:end] + suffix)
81
+ cursor = end
82
+ fence = next_fence
83
+
84
+ return tuple(chunks)
85
+
86
+
87
+ def _preferred_boundary(
88
+ content: str, cursor: int, end: int, minimum: int
89
+ ) -> int | None:
90
+ for separator in ("\n\n", "\n"):
91
+ index = content.rfind(separator, minimum, end)
92
+ if index >= minimum:
93
+ return index + len(separator)
94
+ for index in range(end - 1, minimum - 1, -1):
95
+ if content[index].isspace():
96
+ return index + 1
97
+ return None
98
+
99
+
100
+ def _advance_fence(
101
+ fence: _Fence | None,
102
+ segment: str,
103
+ *,
104
+ initial_line_boundary: bool,
105
+ terminal_line_complete: bool,
106
+ ) -> _Fence | None:
107
+ lines = segment.splitlines(keepends=True)
108
+ for index, line in enumerate(lines):
109
+ complete = (index > 0 or initial_line_boundary) and (
110
+ line.endswith(("\n", "\r"))
111
+ or (index == len(lines) - 1 and terminal_line_complete)
112
+ )
113
+ if not complete:
114
+ continue
115
+ stripped = line.strip()
116
+ if not stripped:
117
+ continue
118
+ if fence is None:
119
+ marker_char = stripped[0]
120
+ if marker_char not in {"`", "~"}:
121
+ continue
122
+ marker_length = len(stripped) - len(stripped.lstrip(marker_char))
123
+ if marker_length < 3:
124
+ continue
125
+ marker = marker_char * marker_length
126
+ info = stripped[marker_length:]
127
+ if marker_char == "`" and "`" in info:
128
+ continue
129
+ fence = _Fence(marker=marker, opening=stripped)
130
+ continue
131
+ if stripped[0] != fence.marker[0]:
132
+ continue
133
+ marker_length = len(stripped) - len(stripped.lstrip(fence.marker[0]))
134
+ if marker_length >= len(fence.marker) and not stripped[marker_length:].strip():
135
+ fence = None
136
+ return fence
137
+
138
+
139
+ def _closing_suffix(content: str, fence: _Fence | None) -> str:
140
+ if fence is None:
141
+ return ""
142
+ separator = "" if content.endswith(("\n", "\r")) else "\n"
143
+ return f"{separator}{fence.marker}"
144
+
145
+
146
+ __all__ = ["split_markdown"]
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from ...core.channel import ChannelContext, IChannel
6
+ from .channel import WeComChannel
7
+
8
+
9
+ def create_channel(context: ChannelContext) -> IChannel:
10
+ bot_id = context.options.get("bot_id")
11
+ websocket_url = context.options.get("websocket_url")
12
+ if not isinstance(bot_id, str) or not bot_id:
13
+ raise ValueError("channel.wecom.bot_id is required")
14
+ if websocket_url is not None and (
15
+ not isinstance(websocket_url, str) or not websocket_url
16
+ ):
17
+ raise ValueError("channel.wecom.websocket_url must be non-empty text")
18
+ secret = os.environ.get("BCN_WECOM_BOT_SECRET")
19
+ if not secret:
20
+ raise ValueError("BCN_WECOM_BOT_SECRET is required")
21
+ return WeComChannel(
22
+ context,
23
+ bot_id=bot_id,
24
+ secret=secret,
25
+ websocket_url=websocket_url or "wss://openws.work.weixin.qq.com",
26
+ )
27
+
28
+
29
+ __all__ = ["create_channel"]
@@ -0,0 +1,5 @@
1
+ """Provider-neutral application contracts and domain models."""
2
+
3
+ from .client import CLIENT_INFO, CLIENT_NAME, CLIENT_VERSION, ClientInfo
4
+
5
+ __all__ = ["CLIENT_INFO", "CLIENT_NAME", "CLIENT_VERSION", "ClientInfo"]
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Protocol
5
+
6
+ from .models import ApprovalRequest, ApprovalResult
7
+
8
+
9
+ class IApprovalHandler(Protocol):
10
+ """Neutral callback used by a runtime adapter for approval requests."""
11
+
12
+ async def request_approval(
13
+ self, request: ApprovalRequest, *, timeout: float
14
+ ) -> ApprovalResult:
15
+ """Route one request to the current Channel approval policy."""
16
+ ...
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class ApprovalBinding:
21
+ """Route one runtime approval request to its current Channel session."""
22
+
23
+ request_id: str
24
+ bcn_session_id: str
25
+ channel_session_id: str
26
+ runtime_session_id: str
27
+ turn_id: str | None = None
28
+
29
+ def __post_init__(self) -> None:
30
+ for value, field_name in (
31
+ (self.request_id, "request_id"),
32
+ (self.bcn_session_id, "bcn_session_id"),
33
+ (self.channel_session_id, "channel_session_id"),
34
+ (self.runtime_session_id, "runtime_session_id"),
35
+ ):
36
+ if not isinstance(value, str) or not value:
37
+ raise ValueError(f"{field_name} must be a non-empty string")
38
+ if self.turn_id is not None and (
39
+ not isinstance(self.turn_id, str) or not self.turn_id
40
+ ):
41
+ raise ValueError("turn_id must be a non-empty string when present")
42
+
43
+ def matches(self, request: ApprovalRequest) -> bool:
44
+ """Ensure a response is returned to the same runtime request context."""
45
+
46
+ return (
47
+ self.request_id == request.request_id
48
+ and self.bcn_session_id == request.session_id
49
+ and self.runtime_session_id == request.runtime_session_id
50
+ and self.turn_id == request.turn_id
51
+ )
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+ from enum import StrEnum
6
+
7
+ from .correlation import CorrelationContext
8
+ from .models import RuntimeEventState
9
+ from .observability import LogLevel
10
+
11
+
12
+ class ErrorKind(StrEnum):
13
+ CANCELLED = "cancelled"
14
+ TIMEOUT = "timeout"
15
+ VALIDATION = "validation"
16
+ SESSION_NOT_FOUND = "session_not_found"
17
+ TARGET_NOT_REPLYABLE = "target_not_replyable"
18
+ EMPTY_BODY = "empty_body"
19
+ FRESH_CHECK_REQUIRED = "fresh_check_required"
20
+ FRESH_CHECK_FAILED = "fresh_check_failed"
21
+ PROVIDER_FAILED = "provider_failed"
22
+ PROVIDER_PARTIAL = "provider_partial"
23
+ PROVIDER_UNKNOWN = "provider_unknown"
24
+ PROTOCOL = "protocol"
25
+ STORAGE = "storage"
26
+ SHUTDOWN_TIMEOUT = "shutdown_timeout"
27
+ INTERNAL = "internal"
28
+
29
+
30
+ _FORBIDDEN_METADATA_KEYS = frozenset(
31
+ {
32
+ "access_token",
33
+ "api_key",
34
+ "authorization",
35
+ "body",
36
+ "cookie",
37
+ "credential",
38
+ "payload",
39
+ "raw_payload",
40
+ "secret",
41
+ "token",
42
+ }
43
+ )
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class AuditEvent:
48
+ """Sanitized append-only event contract shared by all adapters."""
49
+
50
+ event_name: str
51
+ state: RuntimeEventState
52
+ created_at_ms: int
53
+ correlation: CorrelationContext
54
+ level: LogLevel = LogLevel.INFO
55
+ duration_ms: int | None = None
56
+ error_kind: ErrorKind | None = None
57
+ error_type: str | None = None
58
+ error_message: str | None = None
59
+ traceback_ref: str | None = None
60
+ metadata: Mapping[str, object] = field(default_factory=dict)
61
+
62
+ def __post_init__(self) -> None:
63
+ if not isinstance(self.event_name, str) or not self.event_name:
64
+ raise ValueError("event_name must be a non-empty string")
65
+ if (
66
+ isinstance(self.created_at_ms, bool)
67
+ or not isinstance(self.created_at_ms, int)
68
+ or self.created_at_ms < 0
69
+ ):
70
+ raise ValueError("created_at_ms must be a non-negative integer")
71
+ if self.duration_ms is not None and (
72
+ isinstance(self.duration_ms, bool)
73
+ or not isinstance(self.duration_ms, int)
74
+ or self.duration_ms < 0
75
+ ):
76
+ raise ValueError("duration_ms must be a non-negative integer when present")
77
+ if self.error_kind is None and any(
78
+ value is not None
79
+ for value in (
80
+ self.error_type,
81
+ self.error_message,
82
+ self.traceback_ref,
83
+ )
84
+ ):
85
+ raise ValueError("error details require an error_kind")
86
+ for value, field_name in (
87
+ (self.error_type, "error_type"),
88
+ (self.error_message, "error_message"),
89
+ (self.traceback_ref, "traceback_ref"),
90
+ ):
91
+ if value is not None and (not isinstance(value, str) or not value):
92
+ raise ValueError(
93
+ f"{field_name} must be a non-empty string when present"
94
+ )
95
+ for key in self.metadata:
96
+ if not isinstance(key, str):
97
+ raise TypeError("audit metadata keys must be strings")
98
+ if key.casefold() in _FORBIDDEN_METADATA_KEYS:
99
+ raise ValueError(
100
+ f"audit metadata cannot contain sensitive field: {key}"
101
+ )
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterable, AsyncIterator, Callable, Mapping
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Protocol
7
+
8
+ from .approval import IApprovalHandler
9
+ from .lifecycle import IAsyncLifecycle
10
+ from .models import (
11
+ ChannelTargetKind,
12
+ InboundAttachment,
13
+ InboundMessage,
14
+ OutboundMessage,
15
+ StreamEvent,
16
+ )
17
+ from .outcomes import ProviderCallResult
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ChannelDeliveryReceipt:
22
+ """Provider receipt fields safe for the core delivery audit."""
23
+
24
+ provider_message_id: str | None = None
25
+ provider_receipt_ref: str | None = None
26
+
27
+ def __post_init__(self) -> None:
28
+ for value, field_name in (
29
+ (self.provider_message_id, "provider_message_id"),
30
+ (self.provider_receipt_ref, "provider_receipt_ref"),
31
+ ):
32
+ if value is not None and (not isinstance(value, str) or not value):
33
+ raise ValueError(
34
+ f"{field_name} must be a non-empty string when present"
35
+ )
36
+ if self.provider_message_id is None and self.provider_receipt_ref is None:
37
+ raise ValueError(
38
+ "a channel delivery receipt requires a provider identifier"
39
+ )
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class ChannelSendRequest:
44
+ """Transient provider mapping resolved behind the runtime command boundary."""
45
+
46
+ outbound: OutboundMessage
47
+ target_kind: ChannelTargetKind
48
+ provider_thread_id: str
49
+ provider_reply_to_message_id: str | None = None
50
+
51
+ def __post_init__(self) -> None:
52
+ if not self.provider_thread_id:
53
+ raise ValueError("provider_thread_id must be non-empty")
54
+ if (
55
+ self.provider_reply_to_message_id is not None
56
+ and not self.provider_reply_to_message_id
57
+ ):
58
+ raise ValueError("provider_reply_to_message_id must be non-empty")
59
+
60
+
61
+ class IAttachmentMaterializer(Protocol):
62
+ async def materialize(
63
+ self,
64
+ source: bytes | AsyncIterable[bytes],
65
+ *,
66
+ name: str,
67
+ kind: str,
68
+ media_type: str | None = None,
69
+ ) -> InboundAttachment:
70
+ """Persist one bounded plaintext attachment in the shared workspace."""
71
+ ...
72
+
73
+ def failed(
74
+ self,
75
+ *,
76
+ name: str,
77
+ kind: str,
78
+ error: str,
79
+ media_type: str | None = None,
80
+ ) -> InboundAttachment:
81
+ """Create a terminal descriptor without exposing provider references."""
82
+ ...
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class ChannelContext:
87
+ attachments: IAttachmentMaterializer
88
+ options: Mapping[str, object]
89
+ workspace: Callable[[], Path]
90
+
91
+
92
+ class IApproval(IApprovalHandler, Protocol):
93
+ """Channel-owned approval policy for one bcn session."""
94
+
95
+
96
+ class IChannel(IAsyncLifecycle, IApproval, Protocol):
97
+ """Normalized inbound, outbound delivery, and approval contract."""
98
+
99
+ @property
100
+ def name(self) -> str:
101
+ """Return the stable entry-point identity of this adapter."""
102
+ ...
103
+
104
+ @property
105
+ def health(self) -> Mapping[str, object]:
106
+ """Return non-sensitive lifecycle and ingress capability details."""
107
+ ...
108
+
109
+ def receive(self) -> AsyncIterator[InboundMessage]:
110
+ """Return a cancellable stream of normalized inbound messages."""
111
+ ...
112
+
113
+ def offer_stream_event(self, event: StreamEvent) -> None:
114
+ """Offer one transient event without waiting for channel delivery."""
115
+ ...
116
+
117
+ async def send(
118
+ self, request: ChannelSendRequest, *, timeout: float
119
+ ) -> ProviderCallResult[ChannelDeliveryReceipt]:
120
+ """Deliver one outbound message without hiding unknown provider status."""
121
+ ...
@@ -0,0 +1,30 @@
1
+ """Provider-neutral identity for the bcn client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .. import __version__
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class ClientInfo:
12
+ """Canonical client identity exposed to provider protocol adapters."""
13
+
14
+ name: str
15
+ version: str
16
+
17
+ def __post_init__(self) -> None:
18
+ for field_name, value in (("name", self.name), ("version", self.version)):
19
+ if not isinstance(value, str) or not value:
20
+ raise ValueError(f"{field_name} must be a non-empty string")
21
+ if "\r" in value or "\n" in value:
22
+ raise ValueError(f"{field_name} must not contain line breaks")
23
+
24
+
25
+ CLIENT_NAME = "bcn"
26
+ CLIENT_VERSION = __version__
27
+ CLIENT_INFO = ClientInfo(name=CLIENT_NAME, version=CLIENT_VERSION)
28
+
29
+
30
+ __all__ = ["CLIENT_INFO", "CLIENT_NAME", "CLIENT_VERSION", "ClientInfo"]
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Protocol
5
+
6
+ from .models import InboundMessage, OutboundMessage
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class MessageCheckResult:
11
+ """Drain result with a snapshot independent from the delivery cursor."""
12
+
13
+ messages: tuple[InboundMessage, ...]
14
+ snapshot_seq: int
15
+ delivered_through_seq: int
16
+ referenced_messages: tuple[InboundMessage, ...] = ()
17
+
18
+ def __post_init__(self) -> None:
19
+ if self.snapshot_seq < 0 or self.delivered_through_seq < 0:
20
+ raise ValueError("message sequence values must be non-negative")
21
+ if self.delivered_through_seq > self.snapshot_seq:
22
+ raise ValueError("delivered_through_seq cannot exceed snapshot_seq")
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class MessageReadResult:
27
+ """Non-draining history result with the observed inbox snapshot."""
28
+
29
+ messages: tuple[InboundMessage, ...]
30
+ snapshot_seq: int
31
+ first_seq: int | None = None
32
+ last_seq: int | None = None
33
+ referenced_messages: tuple[InboundMessage, ...] = ()
34
+
35
+ def __post_init__(self) -> None:
36
+ if self.snapshot_seq < 0:
37
+ raise ValueError("snapshot_seq must be non-negative")
38
+ if (self.first_seq is None) != (self.last_seq is None):
39
+ raise ValueError("first_seq and last_seq must be provided together")
40
+ if (
41
+ self.first_seq is not None
42
+ and self.last_seq is not None
43
+ and (self.first_seq < 0 or self.last_seq < self.first_seq)
44
+ ):
45
+ raise ValueError("history sequence bounds are invalid")
46
+
47
+
48
+ class SessionNotFoundError(ValueError):
49
+ """A command referenced a bcn session that is not persisted on this node."""
50
+
51
+
52
+ class ICommandService(Protocol):
53
+ """Session-scoped command surface used by the local wrapper."""
54
+
55
+ async def check(self, session_id: str) -> MessageCheckResult:
56
+ """Read new messages and advance only the delivery cursor."""
57
+ ...
58
+
59
+ async def read(
60
+ self,
61
+ session_id: str,
62
+ *,
63
+ target: str,
64
+ around_message_id: str | None = None,
65
+ limit: int = 100,
66
+ ) -> MessageReadResult:
67
+ """Read history without advancing the delivery cursor."""
68
+ ...
69
+
70
+ async def send(
71
+ self,
72
+ *,
73
+ session_id: str,
74
+ command_id: str,
75
+ target: str,
76
+ body: str,
77
+ created_at_ms: int,
78
+ reply_to_message_id: str | None = None,
79
+ ) -> OutboundMessage:
80
+ """Run the session fresh-check before calling the Channel port."""
81
+ ...
82
+
83
+ async def unfollow(self, session_id: str, *, target: str) -> bool:
84
+ """Disable future group notifications and report whether state changed."""
85
+ ...
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Protocol
5
+
6
+
7
+ class ISessionConcurrency(Protocol):
8
+ """Return the shared lock for one bcn session.
9
+
10
+ Command, cursor, turn, and outbound fresh-check operations for the same
11
+ session must use the same lock. Locks for different sessions are
12
+ independent and must not be acquired by a global node lock.
13
+ """
14
+
15
+ def for_session(self, session_id: str) -> asyncio.Lock:
16
+ """Return a stable lock keyed by the opaque bcn session id."""
17
+ ...
18
+
19
+
20
+ class SessionLockRegistry:
21
+ """In-memory per-session lock registry for one running node process."""
22
+
23
+ def __init__(self) -> None:
24
+ self._locks: dict[str, asyncio.Lock] = {}
25
+
26
+ def for_session(self, session_id: str) -> asyncio.Lock:
27
+ if not isinstance(session_id, str) or not session_id:
28
+ raise ValueError("session_id must be a non-empty string")
29
+ return self._locks.setdefault(session_id, asyncio.Lock())
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class CorrelationContext:
8
+ """Provider-neutral identifiers shared by runtime, channel, and command flows."""
9
+
10
+ node_id: str | None = None
11
+ channel: str | None = None
12
+ channel_session_id: str | None = None
13
+ bcn_session_id: str | None = None
14
+ runtime_session_id: str | None = None
15
+ turn_id: str | None = None
16
+ request_id: str | None = None
17
+ command_id: str | None = None
18
+ inbound_seq: int | None = None
19
+ outbound_message_id: str | None = None
20
+ provider_request_id: str | None = None
21
+ provider_thread_id: str | None = None
22
+ provider_turn_id: str | None = None
23
+
24
+ def __post_init__(self) -> None:
25
+ for value, field_name in (
26
+ (self.node_id, "node_id"),
27
+ (self.channel, "channel"),
28
+ (self.channel_session_id, "channel_session_id"),
29
+ (self.bcn_session_id, "bcn_session_id"),
30
+ (self.runtime_session_id, "runtime_session_id"),
31
+ (self.turn_id, "turn_id"),
32
+ (self.request_id, "request_id"),
33
+ (self.command_id, "command_id"),
34
+ (self.outbound_message_id, "outbound_message_id"),
35
+ (self.provider_request_id, "provider_request_id"),
36
+ (self.provider_thread_id, "provider_thread_id"),
37
+ (self.provider_turn_id, "provider_turn_id"),
38
+ ):
39
+ if value is not None and (not isinstance(value, str) or not value):
40
+ raise ValueError(
41
+ f"{field_name} must be a non-empty string when present"
42
+ )
43
+ if self.inbound_seq is not None and (
44
+ isinstance(self.inbound_seq, bool)
45
+ or not isinstance(self.inbound_seq, int)
46
+ or self.inbound_seq < 0
47
+ ):
48
+ raise ValueError("inbound_seq must be a non-negative integer when present")