@oneciel-ai/ciel-runtime 0.1.1 → 0.2.0
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/README.md +132 -0
- package/ciel-runtime-menu.py +56 -6
- package/ciel_runtime.py +9379 -35909
- package/ciel_runtime_support/advisor_client.py +193 -0
- package/ciel_runtime_support/advisor_policy.py +320 -0
- package/ciel_runtime_support/advisor_refinement.py +160 -0
- package/ciel_runtime_support/advisor_request_builder.py +261 -0
- package/ciel_runtime_support/agy_installer.py +169 -0
- package/ciel_runtime_support/agy_mcp_restore.py +182 -0
- package/ciel_runtime_support/anthropic_model_policy.py +186 -0
- package/ciel_runtime_support/anthropic_response_writer.py +255 -0
- package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
- package/ciel_runtime_support/api_key_cooldown.py +159 -0
- package/ciel_runtime_support/architecture.py +488 -1
- package/ciel_runtime_support/architecture_budget.py +42 -0
- package/ciel_runtime_support/channel_backlog.py +90 -0
- package/ciel_runtime_support/channel_cli.py +119 -0
- package/ciel_runtime_support/channel_compact_injection.py +82 -0
- package/ciel_runtime_support/channel_compact_poll.py +67 -0
- package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
- package/ciel_runtime_support/channel_config_service.py +281 -0
- package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
- package/ciel_runtime_support/channel_connection_registry.py +128 -0
- package/ciel_runtime_support/channel_connection_worker.py +284 -0
- package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
- package/ciel_runtime_support/channel_cursor_repository.py +89 -0
- package/ciel_runtime_support/channel_cursor_service.py +178 -0
- package/ciel_runtime_support/channel_event_identity.py +212 -0
- package/ciel_runtime_support/channel_event_projection.py +315 -0
- package/ciel_runtime_support/channel_inflight.py +127 -0
- package/ciel_runtime_support/channel_injection.py +115 -0
- package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
- package/ciel_runtime_support/channel_launch_policy.py +180 -0
- package/ciel_runtime_support/channel_llm_context.py +156 -0
- package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
- package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
- package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
- package/ciel_runtime_support/channel_mcp_tools.py +240 -0
- package/ciel_runtime_support/channel_mcp_transport.py +394 -0
- package/ciel_runtime_support/channel_message_dedupe.py +65 -0
- package/ciel_runtime_support/channel_message_policy.py +256 -0
- package/ciel_runtime_support/channel_message_prompt.py +305 -0
- package/ciel_runtime_support/channel_message_repository.py +234 -0
- package/ciel_runtime_support/channel_notification_projection.py +217 -0
- package/ciel_runtime_support/channel_panel.py +162 -0
- package/ciel_runtime_support/channel_pending_injection.py +209 -0
- package/ciel_runtime_support/channel_pending_poll.py +109 -0
- package/ciel_runtime_support/channel_probe_cache.py +433 -0
- package/ciel_runtime_support/channel_probe_report.py +101 -0
- package/ciel_runtime_support/channel_runtime_environment.py +181 -0
- package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
- package/ciel_runtime_support/channel_session_repository.py +90 -0
- package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
- package/ciel_runtime_support/channel_terminal_input.py +277 -0
- package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
- package/ciel_runtime_support/channel_tool_context.py +166 -0
- package/ciel_runtime_support/channel_transcript.py +414 -0
- package/ciel_runtime_support/channel_transcript_repository.py +96 -0
- package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
- package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
- package/ciel_runtime_support/chat_files.py +138 -0
- package/ciel_runtime_support/chat_http_controller.py +235 -0
- package/ciel_runtime_support/claude_environment.py +375 -0
- package/ciel_runtime_support/claude_router.py +247 -193
- package/ciel_runtime_support/cli_dispatch.py +792 -0
- package/ciel_runtime_support/cli_parser.py +165 -0
- package/ciel_runtime_support/cli_usage.py +100 -0
- package/ciel_runtime_support/codex_app_server.py +20 -5
- package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
- package/ciel_runtime_support/codex_cli.py +42 -6
- package/ciel_runtime_support/codex_config.py +323 -0
- package/ciel_runtime_support/codex_launch_configuration.py +240 -0
- package/ciel_runtime_support/codex_launch_policy.py +66 -0
- package/ciel_runtime_support/codex_mcp_integration.py +195 -0
- package/ciel_runtime_support/codex_mcp_restore.py +304 -0
- package/ciel_runtime_support/codex_model_catalog.py +133 -0
- package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
- package/ciel_runtime_support/codex_router.py +147 -1
- package/ciel_runtime_support/codex_session_repository.py +115 -0
- package/ciel_runtime_support/codex_session_selection.py +114 -0
- package/ciel_runtime_support/command_asset_installer.py +103 -0
- package/ciel_runtime_support/compatibility_probe.py +295 -0
- package/ciel_runtime_support/compatibility_protocol.py +251 -0
- package/ciel_runtime_support/compatibility_runtime.py +166 -0
- package/ciel_runtime_support/compatibility_test.py +370 -0
- package/ciel_runtime_support/config_migrations.py +307 -0
- package/ciel_runtime_support/config_repository.py +175 -0
- package/ciel_runtime_support/config_value_codec.py +64 -0
- package/ciel_runtime_support/configuration_cli.py +374 -0
- package/ciel_runtime_support/context_compaction.py +280 -0
- package/ciel_runtime_support/context_setup.py +208 -0
- package/ciel_runtime_support/context_summary_policy.py +392 -0
- package/ciel_runtime_support/credential_cli.py +104 -0
- package/ciel_runtime_support/credential_management.py +261 -0
- package/ciel_runtime_support/credentials.py +269 -0
- package/ciel_runtime_support/executable_discovery.py +141 -0
- package/ciel_runtime_support/github_copilot_oauth.py +335 -0
- package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
- package/ciel_runtime_support/header_forwarding.py +73 -0
- package/ciel_runtime_support/headless_config.py +221 -0
- package/ciel_runtime_support/http_response.py +129 -0
- package/ciel_runtime_support/install_diagnostics.py +149 -0
- package/ciel_runtime_support/kimi_identity.py +123 -0
- package/ciel_runtime_support/launch_diagnostics.py +204 -0
- package/ciel_runtime_support/launch_state.py +127 -0
- package/ciel_runtime_support/live_api_key_controller.py +58 -0
- package/ciel_runtime_support/llm_config_http.py +148 -0
- package/ciel_runtime_support/llm_option_config.py +259 -0
- package/ciel_runtime_support/llm_presentation_data.py +447 -0
- package/ciel_runtime_support/llm_presets.py +773 -0
- package/ciel_runtime_support/lm_studio_runtime.py +401 -0
- package/ciel_runtime_support/managed_mcp_config.py +144 -0
- package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
- package/ciel_runtime_support/managed_service_cleanup.py +89 -0
- package/ciel_runtime_support/mcp_config_reader.py +230 -0
- package/ciel_runtime_support/mcp_http_proxy.py +607 -0
- package/ciel_runtime_support/mcp_inventory.py +59 -0
- package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
- package/ciel_runtime_support/mcp_probe_codec.py +136 -0
- package/ciel_runtime_support/mcp_probe_transport.py +328 -0
- package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
- package/ciel_runtime_support/mcp_proxy_config.py +107 -0
- package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
- package/ciel_runtime_support/mcp_proxy_process.py +560 -0
- package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
- package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
- package/ciel_runtime_support/mcp_transport.py +146 -0
- package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
- package/ciel_runtime_support/model_catalog_projection.py +61 -0
- package/ciel_runtime_support/model_context_hints.py +109 -0
- package/ciel_runtime_support/model_panel.py +147 -0
- package/ciel_runtime_support/model_registry_repository.py +231 -0
- package/ciel_runtime_support/npm_runtime.py +191 -0
- package/ciel_runtime_support/ollama_catalog.py +462 -0
- package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
- package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
- package/ciel_runtime_support/ollama_context_sync.py +87 -0
- package/ciel_runtime_support/ollama_forwarding.py +449 -0
- package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
- package/ciel_runtime_support/openai_chat_router.py +64 -0
- package/ciel_runtime_support/openai_forwarding.py +194 -0
- package/ciel_runtime_support/openai_responses_router.py +291 -0
- package/ciel_runtime_support/openai_responses_stream.py +135 -0
- package/ciel_runtime_support/output_budget.py +89 -0
- package/ciel_runtime_support/package_lifecycle.py +217 -0
- package/ciel_runtime_support/plan_artifact_controller.py +104 -0
- package/ciel_runtime_support/prelaunch.py +959 -0
- package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
- package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
- package/ciel_runtime_support/prelaunch_terminal.py +764 -0
- package/ciel_runtime_support/process_control.py +708 -0
- package/ciel_runtime_support/prompt_compaction.py +322 -0
- package/ciel_runtime_support/prompt_injection.py +176 -0
- package/ciel_runtime_support/protocols/__init__.py +24 -0
- package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
- package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
- package/ciel_runtime_support/protocols/chat_projection.py +315 -0
- package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
- package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
- package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
- package/ciel_runtime_support/protocols/ollama_response.py +231 -0
- package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
- package/ciel_runtime_support/protocols/openai_responses.py +271 -0
- package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
- package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
- package/ciel_runtime_support/provider_adapters.py +160 -0
- package/ciel_runtime_support/provider_catalog_sources.py +316 -0
- package/ciel_runtime_support/provider_choice.py +201 -0
- package/ciel_runtime_support/provider_compatibility.py +165 -0
- package/ciel_runtime_support/provider_config_mutations.py +361 -0
- package/ciel_runtime_support/provider_configuration_service.py +162 -0
- package/ciel_runtime_support/provider_context.py +308 -0
- package/ciel_runtime_support/provider_contract_projection.py +73 -0
- package/ciel_runtime_support/provider_descriptor.py +82 -0
- package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
- package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
- package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
- package/ciel_runtime_support/provider_limits.py +457 -0
- package/ciel_runtime_support/provider_model_identity.py +139 -0
- package/ciel_runtime_support/provider_model_selection.py +431 -0
- package/ciel_runtime_support/provider_model_specs.py +142 -0
- package/ciel_runtime_support/provider_models.py +263 -0
- package/ciel_runtime_support/provider_network.py +176 -0
- package/ciel_runtime_support/provider_option_cli.py +238 -0
- package/ciel_runtime_support/provider_option_panel.py +275 -0
- package/ciel_runtime_support/provider_option_status.py +192 -0
- package/ciel_runtime_support/provider_policy.py +101 -0
- package/ciel_runtime_support/provider_query_policy.py +67 -0
- package/ciel_runtime_support/provider_readiness.py +112 -0
- package/ciel_runtime_support/provider_request_access.py +131 -0
- package/ciel_runtime_support/provider_request_builder.py +250 -0
- package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
- package/ciel_runtime_support/provider_runtime_info.py +113 -0
- package/ciel_runtime_support/provider_runtime_modes.py +150 -0
- package/ciel_runtime_support/provider_sampling_policy.py +46 -0
- package/ciel_runtime_support/provider_status.py +145 -0
- package/ciel_runtime_support/provider_timeout_policy.py +184 -0
- package/ciel_runtime_support/provider_tool_policy.py +145 -0
- package/ciel_runtime_support/providers/__init__.py +65 -0
- package/ciel_runtime_support/providers/anthropic.py +160 -0
- package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
- package/ciel_runtime_support/providers/base.py +232 -0
- package/ciel_runtime_support/providers/catalog.py +326 -0
- package/ciel_runtime_support/providers/cloud.py +194 -0
- package/ciel_runtime_support/providers/constants.py +54 -0
- package/ciel_runtime_support/providers/deepseek.py +115 -0
- package/ciel_runtime_support/providers/fireworks.py +158 -0
- package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
- package/ciel_runtime_support/providers/kimi.py +297 -0
- package/ciel_runtime_support/providers/lm_studio.py +76 -0
- package/ciel_runtime_support/providers/meta.py +257 -0
- package/ciel_runtime_support/providers/native.py +194 -0
- package/ciel_runtime_support/providers/nim.py +69 -0
- package/ciel_runtime_support/providers/nvidia.py +158 -0
- package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
- package/ciel_runtime_support/providers/ollama.py +181 -0
- package/ciel_runtime_support/providers/ollama_context.py +195 -0
- package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
- package/ciel_runtime_support/providers/opencode.py +220 -0
- package/ciel_runtime_support/providers/opencode_go.py +37 -0
- package/ciel_runtime_support/providers/openrouter.py +57 -0
- package/ciel_runtime_support/providers/vllm.py +65 -0
- package/ciel_runtime_support/providers/zai.py +119 -0
- package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
- package/ciel_runtime_support/rate_limit_policy.py +117 -0
- package/ciel_runtime_support/rate_limit_repository.py +154 -0
- package/ciel_runtime_support/registry.py +46 -0
- package/ciel_runtime_support/request_shortcuts.py +253 -0
- package/ciel_runtime_support/request_trace.py +323 -0
- package/ciel_runtime_support/response_collection.py +209 -0
- package/ciel_runtime_support/router_access.py +238 -0
- package/ciel_runtime_support/router_client_lifecycle.py +366 -0
- package/ciel_runtime_support/router_health_policy.py +101 -0
- package/ciel_runtime_support/router_http.py +513 -0
- package/ciel_runtime_support/router_process_lifecycle.py +401 -0
- package/ciel_runtime_support/router_rate_limit_service.py +285 -0
- package/ciel_runtime_support/router_server_runtime.py +103 -0
- package/ciel_runtime_support/router_shortcuts.py +201 -0
- package/ciel_runtime_support/routing_fallback.py +73 -0
- package/ciel_runtime_support/runtime_activity_repository.py +143 -0
- package/ciel_runtime_support/runtime_adapters.py +104 -0
- package/ciel_runtime_support/runtime_command_factory.py +73 -0
- package/ciel_runtime_support/runtime_compatibility.py +50 -0
- package/ciel_runtime_support/runtime_constants.py +178 -0
- package/ciel_runtime_support/runtime_launch.py +1602 -0
- package/ciel_runtime_support/runtime_llm_options.py +312 -0
- package/ciel_runtime_support/runtime_logging.py +161 -0
- package/ciel_runtime_support/runtime_paths.py +157 -0
- package/ciel_runtime_support/runtime_restart.py +84 -0
- package/ciel_runtime_support/runtime_upgrade.py +149 -0
- package/ciel_runtime_support/secure_json_repository.py +55 -0
- package/ciel_runtime_support/session_import.py +356 -0
- package/ciel_runtime_support/settings_repository.py +8 -0
- package/ciel_runtime_support/slash_command_assets.py +211 -0
- package/ciel_runtime_support/sse_stream.py +57 -0
- package/ciel_runtime_support/sse_trace.py +225 -0
- package/ciel_runtime_support/statusline_script.py +593 -0
- package/ciel_runtime_support/statusline_settings.py +53 -0
- package/ciel_runtime_support/stream_chunk_policy.py +18 -0
- package/ciel_runtime_support/streaming_anthropic.py +1955 -0
- package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
- package/ciel_runtime_support/terminal_platform_io.py +127 -0
- package/ciel_runtime_support/timeout_profile.py +196 -0
- package/ciel_runtime_support/tool_dialects.py +85 -0
- package/ciel_runtime_support/tool_exposure_policy.py +63 -0
- package/ciel_runtime_support/tool_guard_hooks.py +218 -0
- package/ciel_runtime_support/tool_request_projection.py +96 -0
- package/ciel_runtime_support/tool_schema.py +483 -0
- package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
- package/ciel_runtime_support/ui_text.py +266 -0
- package/ciel_runtime_support/upstream_error_policy.py +104 -0
- package/ciel_runtime_support/upstream_retry.py +419 -0
- package/ciel_runtime_support/upstream_stream_io.py +106 -0
- package/ciel_runtime_support/usage_events.py +96 -0
- package/ciel_runtime_support/visible_stream_filters.py +130 -0
- package/ciel_runtime_support/web_endpoints.py +447 -0
- package/ciel_runtime_support/web_ui.py +915 -0
- package/ciel_runtime_support/web_ui_controller.py +189 -0
- package/ciel_runtime_support/windows_console_input.py +137 -0
- package/ciel_runtime_support/windows_console_mode.py +112 -0
- package/docs/Architecture.md +54 -0
- package/docs/Configuration.md +17 -0
- package/docs/Module-Map.md +1093 -26
- package/docs/Providers.md +46 -1
- package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
- package/npm-bin/run-ciel-runtime.js +22 -2
- package/package.json +9 -2
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Network workers for SSE and MCP Streamable HTTP channel connections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from threading import Lock
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from ciel_runtime_support.sse_stream import SseRetryState, SseStreamServices, consume_sse_stream
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class ChannelWorkerEffects:
|
|
17
|
+
log: Callable[[str, str], None]
|
|
18
|
+
dispatch: Callable[[str, str, list[str], str | None], None]
|
|
19
|
+
set_state: Callable[..., None]
|
|
20
|
+
initialize_streamable: Callable[[str], None]
|
|
21
|
+
close_state_session: Callable[[dict[str, Any], str], None]
|
|
22
|
+
streamable_headers: Callable[[dict[str, str], str, str | None, str], dict[str, str]]
|
|
23
|
+
session_not_found: Callable[[urllib.error.HTTPError, str], bool]
|
|
24
|
+
http_error_body: Callable[[urllib.error.HTTPError], str]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class ChannelWorkerPolicy:
|
|
29
|
+
streamable_protocol_version: str
|
|
30
|
+
legacy_sse_protocol_version: str
|
|
31
|
+
parse_bool: Callable[[Any, bool], bool]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class ChannelWorkerStateStore:
|
|
36
|
+
states: dict[str, dict[str, Any]]
|
|
37
|
+
lock: Lock
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _matches(state: dict[str, Any], connection_id: str | None) -> bool:
|
|
41
|
+
return not connection_id or str(state.get("connection_id") or "") == str(connection_id)
|
|
42
|
+
|
|
43
|
+
def snapshot(self, name: str, connection_id: str | None) -> dict[str, Any] | None:
|
|
44
|
+
with self.lock:
|
|
45
|
+
state = self.states.get(name)
|
|
46
|
+
if not state or not state.get("running") or not self._matches(state, connection_id):
|
|
47
|
+
return None
|
|
48
|
+
return dict(state)
|
|
49
|
+
|
|
50
|
+
def running(self, name: str, connection_id: str | None) -> bool:
|
|
51
|
+
return self.snapshot(name, connection_id) is not None
|
|
52
|
+
|
|
53
|
+
def record_reconnect(
|
|
54
|
+
self,
|
|
55
|
+
name: str,
|
|
56
|
+
connection_id: str | None,
|
|
57
|
+
error: str,
|
|
58
|
+
) -> str | None:
|
|
59
|
+
with self.lock:
|
|
60
|
+
state = self.states.get(name)
|
|
61
|
+
if not state or not state.get("running") or not self._matches(state, connection_id):
|
|
62
|
+
return None
|
|
63
|
+
state["last_error"] = error
|
|
64
|
+
state["sse_reconnects"] = int(state.get("sse_reconnects") or 0) + 1
|
|
65
|
+
event_id = state.get("last_sse_event_id")
|
|
66
|
+
return str(event_id) if event_id is not None and str(event_id) != "" else "-"
|
|
67
|
+
|
|
68
|
+
def state_after_initialize(
|
|
69
|
+
self,
|
|
70
|
+
name: str,
|
|
71
|
+
connection_id: str | None,
|
|
72
|
+
parse_bool: Callable[[Any, bool], bool],
|
|
73
|
+
) -> tuple[str, str | None] | None:
|
|
74
|
+
with self.lock:
|
|
75
|
+
state = self.states.get(name)
|
|
76
|
+
if not state or not state.get("running") or not self._matches(state, connection_id):
|
|
77
|
+
return None
|
|
78
|
+
if str(state.get("transport") or "").strip().lower() == "sse":
|
|
79
|
+
return "sse", None
|
|
80
|
+
if not state.get("mcp_initialized"):
|
|
81
|
+
return "retry", None
|
|
82
|
+
session_id = str(state.get("mcp_session_id") or "").strip() or None
|
|
83
|
+
if parse_bool(state.get("streamable_requires_session"), True) and not session_id:
|
|
84
|
+
state["mcp_initialized"] = False
|
|
85
|
+
state["mcp_last_error"] = "streamable_http_missing_session_id"
|
|
86
|
+
return "retry", None
|
|
87
|
+
return "ready", session_id
|
|
88
|
+
|
|
89
|
+
def record_http_error(
|
|
90
|
+
self,
|
|
91
|
+
name: str,
|
|
92
|
+
connection_id: str | None,
|
|
93
|
+
error: urllib.error.HTTPError,
|
|
94
|
+
session_not_found: bool,
|
|
95
|
+
legacy_protocol_version: str,
|
|
96
|
+
) -> tuple[str, str] | None:
|
|
97
|
+
with self.lock:
|
|
98
|
+
state = self.states.get(name)
|
|
99
|
+
if not state or not state.get("running") or not self._matches(state, connection_id):
|
|
100
|
+
return None
|
|
101
|
+
if error.code == 405:
|
|
102
|
+
state.update(
|
|
103
|
+
transport="sse",
|
|
104
|
+
mcp_protocol_version=legacy_protocol_version,
|
|
105
|
+
mcp_initialized=False,
|
|
106
|
+
mcp_session_id=None,
|
|
107
|
+
last_error="streamable_http_405_fallback_sse",
|
|
108
|
+
)
|
|
109
|
+
return "sse", "-"
|
|
110
|
+
if session_not_found:
|
|
111
|
+
state.update(
|
|
112
|
+
mcp_initialized=False,
|
|
113
|
+
mcp_session_id=None,
|
|
114
|
+
mcp_last_error=f"streamable_http_session_not_found:HTTPError:{error.code}",
|
|
115
|
+
)
|
|
116
|
+
state["last_error"] = f"HTTPError: {error.code} {error.reason}"
|
|
117
|
+
state["sse_reconnects"] = int(state.get("sse_reconnects") or 0) + 1
|
|
118
|
+
event_id = state.get("last_sse_event_id")
|
|
119
|
+
resumed = str(event_id) if event_id is not None and str(event_id) != "" else "-"
|
|
120
|
+
return "retry", resumed
|
|
121
|
+
|
|
122
|
+
def state_for_close(self, name: str, connection_id: str | None) -> dict[str, Any] | None:
|
|
123
|
+
with self.lock:
|
|
124
|
+
state = self.states.get(name)
|
|
125
|
+
return dict(state) if state and self._matches(state, connection_id) else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class ChannelConnectionWorker:
|
|
130
|
+
state_store: ChannelWorkerStateStore
|
|
131
|
+
effects: ChannelWorkerEffects
|
|
132
|
+
policy: ChannelWorkerPolicy
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _event_id(value: Any) -> str:
|
|
136
|
+
return str(value) if value is not None and str(value) != "" else "-"
|
|
137
|
+
|
|
138
|
+
def run_sse(self, name: str, connection_id: str | None = None) -> None:
|
|
139
|
+
while snapshot := self.state_store.snapshot(name, connection_id):
|
|
140
|
+
url = str(snapshot.get("url") or "")
|
|
141
|
+
headers = dict(snapshot.get("headers") or {})
|
|
142
|
+
last_event_id = snapshot.get("last_sse_event_id")
|
|
143
|
+
read_timeout = max(5.0, min(3600.0, float(snapshot.get("read_timeout_seconds") or 300.0)))
|
|
144
|
+
retry = SseRetryState(max(1.0, min(60.0, float(snapshot.get("retry_seconds") or 5.0))))
|
|
145
|
+
try:
|
|
146
|
+
request_headers = {**headers, "Accept": "text/event-stream"}
|
|
147
|
+
if last_event_id is not None and str(last_event_id) != "":
|
|
148
|
+
request_headers["Last-Event-ID"] = str(last_event_id)
|
|
149
|
+
request = urllib.request.Request(url, headers=request_headers)
|
|
150
|
+
with urllib.request.urlopen(request, timeout=read_timeout) as response:
|
|
151
|
+
self.effects.set_state(name, last_error=None)
|
|
152
|
+
self.effects.log(
|
|
153
|
+
"INFO",
|
|
154
|
+
f"channel_sse_connected name={name} url={url} last_event_id={self._event_id(last_event_id)}",
|
|
155
|
+
)
|
|
156
|
+
consume_sse_stream(
|
|
157
|
+
response,
|
|
158
|
+
retry,
|
|
159
|
+
"SSE stream ended",
|
|
160
|
+
SseStreamServices(
|
|
161
|
+
should_continue=lambda: self.state_store.running(name, connection_id),
|
|
162
|
+
dispatch=lambda event, data, event_id: self.effects.dispatch(
|
|
163
|
+
name, event, data, event_id
|
|
164
|
+
),
|
|
165
|
+
invalid_retry=lambda value: self.effects.log(
|
|
166
|
+
"WARN", f"channel_sse_invalid_retry name={name} value={value!r}"
|
|
167
|
+
),
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
except Exception as error:
|
|
171
|
+
resumed = self.state_store.record_reconnect(
|
|
172
|
+
name, connection_id, f"{type(error).__name__}: {error}"
|
|
173
|
+
)
|
|
174
|
+
if resumed is None:
|
|
175
|
+
return
|
|
176
|
+
self.effects.log(
|
|
177
|
+
"WARN",
|
|
178
|
+
f"channel_sse_reconnect name={name} last_event_id={resumed} error={type(error).__name__}: {error}",
|
|
179
|
+
)
|
|
180
|
+
time.sleep(retry.seconds)
|
|
181
|
+
|
|
182
|
+
def run_streamable_http(self, name: str, connection_id: str | None = None) -> None:
|
|
183
|
+
switch_to_sse = False
|
|
184
|
+
try:
|
|
185
|
+
while snapshot := self.state_store.snapshot(name, connection_id):
|
|
186
|
+
url = str(snapshot.get("url") or "")
|
|
187
|
+
headers = dict(snapshot.get("headers") or {})
|
|
188
|
+
protocol_version = str(
|
|
189
|
+
snapshot.get("mcp_protocol_version") or self.policy.streamable_protocol_version
|
|
190
|
+
)
|
|
191
|
+
session_id = str(snapshot.get("mcp_session_id") or "").strip() or None
|
|
192
|
+
requires_session = self.policy.parse_bool(snapshot.get("streamable_requires_session"), True)
|
|
193
|
+
needs_initialize = not snapshot.get("mcp_initialized") or (requires_session and not session_id)
|
|
194
|
+
last_event_id = snapshot.get("last_sse_event_id")
|
|
195
|
+
read_timeout = max(5.0, min(3600.0, float(snapshot.get("read_timeout_seconds") or 300.0)))
|
|
196
|
+
retry = SseRetryState(max(1.0, min(60.0, float(snapshot.get("retry_seconds") or 5.0))))
|
|
197
|
+
if needs_initialize:
|
|
198
|
+
self.effects.initialize_streamable(name)
|
|
199
|
+
initialized = self.state_store.state_after_initialize(
|
|
200
|
+
name, connection_id, self.policy.parse_bool
|
|
201
|
+
)
|
|
202
|
+
if initialized is None:
|
|
203
|
+
return
|
|
204
|
+
outcome, session_id = initialized
|
|
205
|
+
if outcome == "sse":
|
|
206
|
+
self.effects.log("INFO", f"channel_http_worker_switching_to_sse name={name}")
|
|
207
|
+
switch_to_sse = True
|
|
208
|
+
break
|
|
209
|
+
if outcome == "retry":
|
|
210
|
+
time.sleep(retry.seconds)
|
|
211
|
+
continue
|
|
212
|
+
try:
|
|
213
|
+
request_headers = self.effects.streamable_headers(
|
|
214
|
+
headers, protocol_version, session_id, "text/event-stream"
|
|
215
|
+
)
|
|
216
|
+
if last_event_id is not None and str(last_event_id) != "":
|
|
217
|
+
request_headers["Last-Event-ID"] = str(last_event_id)
|
|
218
|
+
request = urllib.request.Request(url, headers=request_headers, method="GET")
|
|
219
|
+
with urllib.request.urlopen(request, timeout=read_timeout) as response:
|
|
220
|
+
self.effects.set_state(name, last_error=None)
|
|
221
|
+
self.effects.log(
|
|
222
|
+
"INFO",
|
|
223
|
+
f"channel_http_connected name={name} url={url} session={session_id or '-'} "
|
|
224
|
+
f"last_event_id={self._event_id(last_event_id)}",
|
|
225
|
+
)
|
|
226
|
+
consume_sse_stream(
|
|
227
|
+
response,
|
|
228
|
+
retry,
|
|
229
|
+
"Streamable HTTP SSE stream ended",
|
|
230
|
+
SseStreamServices(
|
|
231
|
+
should_continue=lambda: self.state_store.running(name, connection_id),
|
|
232
|
+
dispatch=lambda event, data, event_id: self.effects.dispatch(
|
|
233
|
+
name, event, data, event_id
|
|
234
|
+
),
|
|
235
|
+
invalid_retry=lambda value: self.effects.log(
|
|
236
|
+
"WARN", f"channel_http_invalid_retry name={name} value={value!r}"
|
|
237
|
+
),
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
except urllib.error.HTTPError as error:
|
|
241
|
+
body_text = self.effects.http_error_body(error)
|
|
242
|
+
session_lost = self.effects.session_not_found(error, body_text)
|
|
243
|
+
outcome = self.state_store.record_http_error(
|
|
244
|
+
name,
|
|
245
|
+
connection_id,
|
|
246
|
+
error,
|
|
247
|
+
session_lost,
|
|
248
|
+
self.policy.legacy_sse_protocol_version,
|
|
249
|
+
)
|
|
250
|
+
if outcome is None:
|
|
251
|
+
return
|
|
252
|
+
action, resumed = outcome
|
|
253
|
+
if action == "sse":
|
|
254
|
+
self.effects.log(
|
|
255
|
+
"WARN",
|
|
256
|
+
f"channel_http_fallback_sse name={name} url={url} reason=HTTPError:405",
|
|
257
|
+
)
|
|
258
|
+
switch_to_sse = True
|
|
259
|
+
break
|
|
260
|
+
event = "session_lost_reinitialize" if session_lost else "reconnect"
|
|
261
|
+
self.effects.log(
|
|
262
|
+
"WARN",
|
|
263
|
+
f"channel_http_{event} name={name} last_event_id={resumed} "
|
|
264
|
+
f"error=HTTPError:{error.code}:{error.reason}",
|
|
265
|
+
)
|
|
266
|
+
time.sleep(retry.seconds)
|
|
267
|
+
except Exception as error:
|
|
268
|
+
resumed = self.state_store.record_reconnect(
|
|
269
|
+
name, connection_id, f"{type(error).__name__}: {error}"
|
|
270
|
+
)
|
|
271
|
+
if resumed is None:
|
|
272
|
+
return
|
|
273
|
+
self.effects.log(
|
|
274
|
+
"WARN",
|
|
275
|
+
f"channel_http_reconnect name={name} last_event_id={resumed} "
|
|
276
|
+
f"error={type(error).__name__}: {error}",
|
|
277
|
+
)
|
|
278
|
+
time.sleep(retry.seconds)
|
|
279
|
+
finally:
|
|
280
|
+
state = self.state_store.state_for_close(name, connection_id)
|
|
281
|
+
if state and not switch_to_sse:
|
|
282
|
+
self.effects.close_state_session(state, "worker_exit")
|
|
283
|
+
if switch_to_sse:
|
|
284
|
+
self.run_sse(name, connection_id)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Recover a channel delivery cursor from queued-only transcript entries."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class ChannelCursorRecoveryPolicy:
|
|
11
|
+
cache_ttl_seconds: float = 5.0
|
|
12
|
+
transcript_max_bytes: int = 8 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class ChannelCursorRecoveryPorts:
|
|
17
|
+
latest_transcript: Callable[[], Path | None]
|
|
18
|
+
read_tail: Callable[..., str]
|
|
19
|
+
queued_command_ids: Callable[[str], set[int]]
|
|
20
|
+
wake_state: Callable[[int, str], str]
|
|
21
|
+
clamp_to_clear_floor: Callable[[int], int]
|
|
22
|
+
now: Callable[[], float]
|
|
23
|
+
log: Callable[[str, str], None]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class ChannelCursorRecoveryService:
|
|
28
|
+
cache: dict[str, Any]
|
|
29
|
+
policy: ChannelCursorRecoveryPolicy
|
|
30
|
+
ports: ChannelCursorRecoveryPorts
|
|
31
|
+
|
|
32
|
+
def recover(self, last_id: int) -> int:
|
|
33
|
+
if last_id <= 0:
|
|
34
|
+
return last_id
|
|
35
|
+
path = self.ports.latest_transcript()
|
|
36
|
+
if path is None:
|
|
37
|
+
return last_id
|
|
38
|
+
marker = self._file_marker(path)
|
|
39
|
+
if marker is None:
|
|
40
|
+
return last_id
|
|
41
|
+
now = self.ports.now()
|
|
42
|
+
if self._cache_is_fresh(last_id, marker, now):
|
|
43
|
+
cached = self.cache.get("recovered_last_id")
|
|
44
|
+
recovered = int(cached) if isinstance(cached, int) else last_id
|
|
45
|
+
return self.ports.clamp_to_clear_floor(recovered)
|
|
46
|
+
text = self.ports.read_tail(path, max_bytes=self.policy.transcript_max_bytes)
|
|
47
|
+
recovered = self._recover_from_text(last_id, text)
|
|
48
|
+
self.cache.update(
|
|
49
|
+
{
|
|
50
|
+
"checked_at": now,
|
|
51
|
+
"last_id": last_id,
|
|
52
|
+
"marker": marker,
|
|
53
|
+
"recovered_last_id": recovered,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
return self.ports.clamp_to_clear_floor(recovered)
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _file_marker(path: Path) -> tuple[str, int, int] | None:
|
|
60
|
+
try:
|
|
61
|
+
stat = path.stat()
|
|
62
|
+
except OSError:
|
|
63
|
+
return None
|
|
64
|
+
return str(path), int(stat.st_mtime_ns), int(stat.st_size)
|
|
65
|
+
|
|
66
|
+
def _cache_is_fresh(self, last_id: int, marker: tuple[str, int, int], now: float) -> bool:
|
|
67
|
+
return bool(
|
|
68
|
+
self.cache.get("last_id") == last_id
|
|
69
|
+
and self.cache.get("marker") == marker
|
|
70
|
+
and now - float(self.cache.get("checked_at") or 0.0) < self.policy.cache_ttl_seconds
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def _recover_from_text(self, last_id: int, text: str) -> int:
|
|
74
|
+
if not text:
|
|
75
|
+
return last_id
|
|
76
|
+
for message_id in sorted(self.ports.queued_command_ids(text)):
|
|
77
|
+
if message_id <= last_id and self.ports.wake_state(message_id, text) == "missing":
|
|
78
|
+
recovered = max(0, message_id - 1)
|
|
79
|
+
self.ports.log(
|
|
80
|
+
"WARN",
|
|
81
|
+
"channel_stdin_proxy_recover_queued_only "
|
|
82
|
+
f"message_id={message_id} cursor={last_id} recovered_cursor={recovered}",
|
|
83
|
+
)
|
|
84
|
+
return recovered
|
|
85
|
+
return last_id
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__ = [
|
|
89
|
+
"ChannelCursorRecoveryPolicy",
|
|
90
|
+
"ChannelCursorRecoveryPorts",
|
|
91
|
+
"ChannelCursorRecoveryService",
|
|
92
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class CursorReadResolution:
|
|
11
|
+
value: int
|
|
12
|
+
persist: bool = False
|
|
13
|
+
rolled_back: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ChannelCursorStatePolicy:
|
|
17
|
+
"""Merge durable and process-local cursor state monotonically."""
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def resolve_read(
|
|
21
|
+
file_cursor: int | None,
|
|
22
|
+
cached_cursor: int | None,
|
|
23
|
+
scan_cursor: Callable[[], int],
|
|
24
|
+
) -> CursorReadResolution:
|
|
25
|
+
tail = max(0, scan_cursor())
|
|
26
|
+
if file_cursor is not None:
|
|
27
|
+
cursor = max(file_cursor, cached_cursor or 0)
|
|
28
|
+
elif cached_cursor is not None:
|
|
29
|
+
cursor = cached_cursor
|
|
30
|
+
else:
|
|
31
|
+
return CursorReadResolution(tail, persist=True)
|
|
32
|
+
if cursor > tail:
|
|
33
|
+
return CursorReadResolution(0, persist=True, rolled_back=True)
|
|
34
|
+
return CursorReadResolution(cursor)
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def newer(candidate: int | None, current: int) -> int | None:
|
|
38
|
+
if candidate is None or candidate <= current:
|
|
39
|
+
return None
|
|
40
|
+
return candidate
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class ChannelCursorRepository:
|
|
45
|
+
path: Path
|
|
46
|
+
log: Callable[[str, str], None]
|
|
47
|
+
|
|
48
|
+
def read(self) -> int | None:
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
return None
|
|
53
|
+
except (OSError, UnicodeError, json.JSONDecodeError, TypeError) as exc:
|
|
54
|
+
self.log(
|
|
55
|
+
"WARN",
|
|
56
|
+
f"channel_cursor_read_failed path={self.path} error={type(exc).__name__}: {exc}",
|
|
57
|
+
)
|
|
58
|
+
return None
|
|
59
|
+
if not isinstance(data, dict):
|
|
60
|
+
self.log("WARN", f"channel_cursor_read_failed path={self.path} error=invalid_payload")
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
return max(0, int(data.get("last_id") or 0))
|
|
64
|
+
except (TypeError, ValueError) as exc:
|
|
65
|
+
self.log(
|
|
66
|
+
"WARN",
|
|
67
|
+
f"channel_cursor_read_failed path={self.path} error={type(exc).__name__}: {exc}",
|
|
68
|
+
)
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def write(self, last_id: int, *, metadata: dict[str, Any] | None = None) -> bool:
|
|
72
|
+
try:
|
|
73
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
payload: dict[str, Any] = {"last_id": max(0, int(last_id))}
|
|
75
|
+
if metadata:
|
|
76
|
+
payload.update(metadata)
|
|
77
|
+
temporary = self.path.with_suffix(".json.tmp")
|
|
78
|
+
temporary.write_text(
|
|
79
|
+
json.dumps(payload, separators=(",", ":")) + "\n",
|
|
80
|
+
encoding="utf-8",
|
|
81
|
+
)
|
|
82
|
+
temporary.replace(self.path)
|
|
83
|
+
return True
|
|
84
|
+
except (OSError, TypeError, ValueError, OverflowError) as exc:
|
|
85
|
+
self.log(
|
|
86
|
+
"WARN",
|
|
87
|
+
f"channel_cursor_write_failed path={self.path} error={type(exc).__name__}: {exc}",
|
|
88
|
+
)
|
|
89
|
+
return False
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Application services for durable Channel MCP cursor and resume behavior."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ciel_runtime_support.channel_cursor_repository import ChannelCursorRepository
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class ChannelCursorServices:
|
|
14
|
+
repository: ChannelCursorRepository
|
|
15
|
+
lock: Any
|
|
16
|
+
cached: Callable[[], int | None]
|
|
17
|
+
cache: Callable[[int], None]
|
|
18
|
+
scan_tail: Callable[[], int]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChannelCursorService:
|
|
22
|
+
def __init__(self, services: ChannelCursorServices) -> None:
|
|
23
|
+
self.services = services
|
|
24
|
+
|
|
25
|
+
def read_locked(self) -> int:
|
|
26
|
+
cached = self.services.cached()
|
|
27
|
+
if cached is not None:
|
|
28
|
+
return cached
|
|
29
|
+
persisted = self.services.repository.read()
|
|
30
|
+
cursor = persisted if persisted is not None else max(0, self.services.scan_tail())
|
|
31
|
+
self.services.cache(cursor)
|
|
32
|
+
if persisted is None:
|
|
33
|
+
self.services.repository.write(cursor)
|
|
34
|
+
return cursor
|
|
35
|
+
|
|
36
|
+
def ensure_initialized(self) -> int:
|
|
37
|
+
with self.services.lock:
|
|
38
|
+
return self.read_locked()
|
|
39
|
+
|
|
40
|
+
def update(self, last_id: int) -> None:
|
|
41
|
+
if last_id < 0:
|
|
42
|
+
return
|
|
43
|
+
with self.services.lock:
|
|
44
|
+
if last_id <= self.read_locked():
|
|
45
|
+
return
|
|
46
|
+
cursor = int(last_id)
|
|
47
|
+
self.services.cache(cursor)
|
|
48
|
+
self.services.repository.write(cursor)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_channel_event_id(value: Any) -> int | None:
|
|
52
|
+
text = str(value or "").strip()
|
|
53
|
+
if not text:
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
return max(0, int(text))
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class ChannelResumeServices:
|
|
63
|
+
query_params: Callable[[Any], Mapping[str, list[str]]]
|
|
64
|
+
first_param: Callable[[Mapping[str, list[str]], str], str | None]
|
|
65
|
+
ensure_cursor: Callable[[], int]
|
|
66
|
+
update_cursor: Callable[[int], None]
|
|
67
|
+
log: Callable[[str, str], None]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ChannelResumePolicy:
|
|
71
|
+
def __init__(self, services: ChannelResumeServices) -> None:
|
|
72
|
+
self.services = services
|
|
73
|
+
|
|
74
|
+
def client_last_event_id(self, handler: Any) -> int | None:
|
|
75
|
+
try:
|
|
76
|
+
event_id = parse_channel_event_id(handler.headers.get("Last-Event-ID"))
|
|
77
|
+
if event_id is not None:
|
|
78
|
+
return event_id
|
|
79
|
+
except (AttributeError, TypeError, ValueError) as exc:
|
|
80
|
+
self.services.log(
|
|
81
|
+
"WARN",
|
|
82
|
+
f"channel_mcp_last_event_header_invalid error={type(exc).__name__}: {exc}",
|
|
83
|
+
)
|
|
84
|
+
try:
|
|
85
|
+
params = self.services.query_params(handler)
|
|
86
|
+
for key in ("lastEventId", "last_event_id", "last_id"):
|
|
87
|
+
event_id = parse_channel_event_id(self.services.first_param(params, key))
|
|
88
|
+
if event_id is not None:
|
|
89
|
+
return event_id
|
|
90
|
+
except (AttributeError, TypeError, ValueError) as exc:
|
|
91
|
+
self.services.log(
|
|
92
|
+
"WARN",
|
|
93
|
+
f"channel_mcp_last_event_query_invalid error={type(exc).__name__}: {exc}",
|
|
94
|
+
)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def session_start_last_id(self, handler: Any) -> int:
|
|
98
|
+
cursor_last_id = self.services.ensure_cursor()
|
|
99
|
+
client_last_id = self.client_last_event_id(handler)
|
|
100
|
+
if client_last_id is None:
|
|
101
|
+
return cursor_last_id
|
|
102
|
+
if client_last_id > cursor_last_id:
|
|
103
|
+
self.services.update_cursor(client_last_id)
|
|
104
|
+
self.services.log(
|
|
105
|
+
"INFO",
|
|
106
|
+
f"channel_mcp_resume client_last_id={client_last_id} "
|
|
107
|
+
f"cursor_last_id={cursor_last_id}",
|
|
108
|
+
)
|
|
109
|
+
return client_last_id
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True, slots=True)
|
|
113
|
+
class ChannelDeliveryCursorPorts:
|
|
114
|
+
response_status: Callable[[Any], int | None]
|
|
115
|
+
metadata_enabled: Callable[[dict[str, Any] | None], bool]
|
|
116
|
+
delivery_confirmed: Callable[[Any | None], bool]
|
|
117
|
+
commit_if_newer: Callable[[int | None], None]
|
|
118
|
+
log: Callable[[str, str], None]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ChannelDeliveryCursorCommitter:
|
|
122
|
+
"""Commit a pending Channel cursor only after a confirmed HTTP response."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, ports: ChannelDeliveryCursorPorts) -> None:
|
|
125
|
+
self.ports = ports
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _metadata_int(metadata: dict[str, Any], key: str) -> int | None:
|
|
129
|
+
try:
|
|
130
|
+
value = metadata.get(key)
|
|
131
|
+
if value is None or value == "":
|
|
132
|
+
return None
|
|
133
|
+
return max(0, int(value))
|
|
134
|
+
except (TypeError, ValueError):
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def commit(
|
|
138
|
+
self,
|
|
139
|
+
body: dict[str, Any],
|
|
140
|
+
handler: Any | None = None,
|
|
141
|
+
metadata: dict[str, Any] | None = None,
|
|
142
|
+
) -> None:
|
|
143
|
+
if not isinstance(metadata, dict):
|
|
144
|
+
body_metadata = body.get("metadata")
|
|
145
|
+
metadata = body_metadata if isinstance(body_metadata, dict) else {}
|
|
146
|
+
if not metadata:
|
|
147
|
+
return
|
|
148
|
+
if handler is not None:
|
|
149
|
+
status = self.ports.response_status(handler)
|
|
150
|
+
if status is None or status < 200 or status >= 400:
|
|
151
|
+
self.ports.log(
|
|
152
|
+
"INFO",
|
|
153
|
+
"channel_delivery_cursor_deferred "
|
|
154
|
+
f"status={status if status is not None else '-'}",
|
|
155
|
+
)
|
|
156
|
+
return
|
|
157
|
+
if (
|
|
158
|
+
self.ports.metadata_enabled(metadata)
|
|
159
|
+
and not self.ports.delivery_confirmed(handler)
|
|
160
|
+
):
|
|
161
|
+
reason = str(
|
|
162
|
+
getattr(
|
|
163
|
+
handler,
|
|
164
|
+
"_ciel_runtime_channel_delivery_reason",
|
|
165
|
+
"unconfirmed",
|
|
166
|
+
)
|
|
167
|
+
or "unconfirmed"
|
|
168
|
+
)
|
|
169
|
+
self.ports.log(
|
|
170
|
+
"INFO",
|
|
171
|
+
f"channel_delivery_cursor_deferred reason={reason}",
|
|
172
|
+
)
|
|
173
|
+
return
|
|
174
|
+
cursor = self._metadata_int(
|
|
175
|
+
metadata,
|
|
176
|
+
"ciel_runtime_channel_cursor_last_id",
|
|
177
|
+
)
|
|
178
|
+
self.ports.commit_if_newer(cursor)
|