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.
- bazaar_compute_node/__init__.py +3 -0
- bazaar_compute_node/app/__init__.py +1 -0
- bazaar_compute_node/app/application.py +398 -0
- bazaar_compute_node/app/attachments.py +154 -0
- bazaar_compute_node/app/command.py +342 -0
- bazaar_compute_node/app/config.py +121 -0
- bazaar_compute_node/app/registry.py +120 -0
- bazaar_compute_node/app/transport.py +264 -0
- bazaar_compute_node/app/windows_pipe.py +463 -0
- bazaar_compute_node/app/wrapper.py +63 -0
- bazaar_compute_node/bcc.py +524 -0
- bazaar_compute_node/cli.py +382 -0
- bazaar_compute_node/contrib/__init__.py +1 -0
- bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
- bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
- bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
- bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
- bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
- bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
- bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
- bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
- bazaar_compute_node/contrib/logging/__init__.py +5 -0
- bazaar_compute_node/contrib/logging/audit.py +61 -0
- bazaar_compute_node/contrib/logging/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
- bazaar_compute_node/contrib/sqlite/codec.py +768 -0
- bazaar_compute_node/contrib/sqlite/database.py +282 -0
- bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
- bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
- bazaar_compute_node/contrib/wecom/__init__.py +1 -0
- bazaar_compute_node/contrib/wecom/channel.py +960 -0
- bazaar_compute_node/contrib/wecom/markdown.py +146 -0
- bazaar_compute_node/contrib/wecom/plugin.py +29 -0
- bazaar_compute_node/core/__init__.py +5 -0
- bazaar_compute_node/core/approval.py +51 -0
- bazaar_compute_node/core/audit.py +101 -0
- bazaar_compute_node/core/channel.py +121 -0
- bazaar_compute_node/core/client.py +30 -0
- bazaar_compute_node/core/command.py +85 -0
- bazaar_compute_node/core/concurrency.py +29 -0
- bazaar_compute_node/core/correlation.py +48 -0
- bazaar_compute_node/core/instruction.py +224 -0
- bazaar_compute_node/core/lifecycle.py +48 -0
- bazaar_compute_node/core/models/__init__.py +63 -0
- bazaar_compute_node/core/models/entities.py +514 -0
- bazaar_compute_node/core/models/states.py +369 -0
- bazaar_compute_node/core/observability.py +47 -0
- bazaar_compute_node/core/orchestration/__init__.py +5 -0
- bazaar_compute_node/core/orchestration/command.py +614 -0
- bazaar_compute_node/core/orchestration/services.py +135 -0
- bazaar_compute_node/core/orchestration/session.py +891 -0
- bazaar_compute_node/core/orchestration/turn.py +451 -0
- bazaar_compute_node/core/outcomes.py +51 -0
- bazaar_compute_node/core/paths.py +19 -0
- bazaar_compute_node/core/runtime.py +118 -0
- bazaar_compute_node/core/storage.py +167 -0
- bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
- bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
- bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
- bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
- 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,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")
|