@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.
Files changed (287) hide show
  1. package/README.md +132 -0
  2. package/ciel-runtime-menu.py +56 -6
  3. package/ciel_runtime.py +9379 -35909
  4. package/ciel_runtime_support/advisor_client.py +193 -0
  5. package/ciel_runtime_support/advisor_policy.py +320 -0
  6. package/ciel_runtime_support/advisor_refinement.py +160 -0
  7. package/ciel_runtime_support/advisor_request_builder.py +261 -0
  8. package/ciel_runtime_support/agy_installer.py +169 -0
  9. package/ciel_runtime_support/agy_mcp_restore.py +182 -0
  10. package/ciel_runtime_support/anthropic_model_policy.py +186 -0
  11. package/ciel_runtime_support/anthropic_response_writer.py +255 -0
  12. package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
  13. package/ciel_runtime_support/api_key_cooldown.py +159 -0
  14. package/ciel_runtime_support/architecture.py +488 -1
  15. package/ciel_runtime_support/architecture_budget.py +42 -0
  16. package/ciel_runtime_support/channel_backlog.py +90 -0
  17. package/ciel_runtime_support/channel_cli.py +119 -0
  18. package/ciel_runtime_support/channel_compact_injection.py +82 -0
  19. package/ciel_runtime_support/channel_compact_poll.py +67 -0
  20. package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
  21. package/ciel_runtime_support/channel_config_service.py +281 -0
  22. package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
  23. package/ciel_runtime_support/channel_connection_registry.py +128 -0
  24. package/ciel_runtime_support/channel_connection_worker.py +284 -0
  25. package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
  26. package/ciel_runtime_support/channel_cursor_repository.py +89 -0
  27. package/ciel_runtime_support/channel_cursor_service.py +178 -0
  28. package/ciel_runtime_support/channel_event_identity.py +212 -0
  29. package/ciel_runtime_support/channel_event_projection.py +315 -0
  30. package/ciel_runtime_support/channel_inflight.py +127 -0
  31. package/ciel_runtime_support/channel_injection.py +115 -0
  32. package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
  33. package/ciel_runtime_support/channel_launch_policy.py +180 -0
  34. package/ciel_runtime_support/channel_llm_context.py +156 -0
  35. package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
  36. package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
  37. package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
  38. package/ciel_runtime_support/channel_mcp_tools.py +240 -0
  39. package/ciel_runtime_support/channel_mcp_transport.py +394 -0
  40. package/ciel_runtime_support/channel_message_dedupe.py +65 -0
  41. package/ciel_runtime_support/channel_message_policy.py +256 -0
  42. package/ciel_runtime_support/channel_message_prompt.py +305 -0
  43. package/ciel_runtime_support/channel_message_repository.py +234 -0
  44. package/ciel_runtime_support/channel_notification_projection.py +217 -0
  45. package/ciel_runtime_support/channel_panel.py +162 -0
  46. package/ciel_runtime_support/channel_pending_injection.py +209 -0
  47. package/ciel_runtime_support/channel_pending_poll.py +109 -0
  48. package/ciel_runtime_support/channel_probe_cache.py +433 -0
  49. package/ciel_runtime_support/channel_probe_report.py +101 -0
  50. package/ciel_runtime_support/channel_runtime_environment.py +181 -0
  51. package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
  52. package/ciel_runtime_support/channel_session_repository.py +90 -0
  53. package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
  54. package/ciel_runtime_support/channel_terminal_input.py +277 -0
  55. package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
  56. package/ciel_runtime_support/channel_tool_context.py +166 -0
  57. package/ciel_runtime_support/channel_transcript.py +414 -0
  58. package/ciel_runtime_support/channel_transcript_repository.py +96 -0
  59. package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
  60. package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
  61. package/ciel_runtime_support/chat_files.py +138 -0
  62. package/ciel_runtime_support/chat_http_controller.py +235 -0
  63. package/ciel_runtime_support/claude_environment.py +375 -0
  64. package/ciel_runtime_support/claude_router.py +247 -193
  65. package/ciel_runtime_support/cli_dispatch.py +792 -0
  66. package/ciel_runtime_support/cli_parser.py +165 -0
  67. package/ciel_runtime_support/cli_usage.py +100 -0
  68. package/ciel_runtime_support/codex_app_server.py +20 -5
  69. package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
  70. package/ciel_runtime_support/codex_cli.py +42 -6
  71. package/ciel_runtime_support/codex_config.py +323 -0
  72. package/ciel_runtime_support/codex_launch_configuration.py +240 -0
  73. package/ciel_runtime_support/codex_launch_policy.py +66 -0
  74. package/ciel_runtime_support/codex_mcp_integration.py +195 -0
  75. package/ciel_runtime_support/codex_mcp_restore.py +304 -0
  76. package/ciel_runtime_support/codex_model_catalog.py +133 -0
  77. package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
  78. package/ciel_runtime_support/codex_router.py +147 -1
  79. package/ciel_runtime_support/codex_session_repository.py +115 -0
  80. package/ciel_runtime_support/codex_session_selection.py +114 -0
  81. package/ciel_runtime_support/command_asset_installer.py +103 -0
  82. package/ciel_runtime_support/compatibility_probe.py +295 -0
  83. package/ciel_runtime_support/compatibility_protocol.py +251 -0
  84. package/ciel_runtime_support/compatibility_runtime.py +166 -0
  85. package/ciel_runtime_support/compatibility_test.py +370 -0
  86. package/ciel_runtime_support/config_migrations.py +307 -0
  87. package/ciel_runtime_support/config_repository.py +175 -0
  88. package/ciel_runtime_support/config_value_codec.py +64 -0
  89. package/ciel_runtime_support/configuration_cli.py +374 -0
  90. package/ciel_runtime_support/context_compaction.py +280 -0
  91. package/ciel_runtime_support/context_setup.py +208 -0
  92. package/ciel_runtime_support/context_summary_policy.py +392 -0
  93. package/ciel_runtime_support/credential_cli.py +104 -0
  94. package/ciel_runtime_support/credential_management.py +261 -0
  95. package/ciel_runtime_support/credentials.py +269 -0
  96. package/ciel_runtime_support/executable_discovery.py +141 -0
  97. package/ciel_runtime_support/github_copilot_oauth.py +335 -0
  98. package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
  99. package/ciel_runtime_support/header_forwarding.py +73 -0
  100. package/ciel_runtime_support/headless_config.py +221 -0
  101. package/ciel_runtime_support/http_response.py +129 -0
  102. package/ciel_runtime_support/install_diagnostics.py +149 -0
  103. package/ciel_runtime_support/kimi_identity.py +123 -0
  104. package/ciel_runtime_support/launch_diagnostics.py +204 -0
  105. package/ciel_runtime_support/launch_state.py +127 -0
  106. package/ciel_runtime_support/live_api_key_controller.py +58 -0
  107. package/ciel_runtime_support/llm_config_http.py +148 -0
  108. package/ciel_runtime_support/llm_option_config.py +259 -0
  109. package/ciel_runtime_support/llm_presentation_data.py +447 -0
  110. package/ciel_runtime_support/llm_presets.py +773 -0
  111. package/ciel_runtime_support/lm_studio_runtime.py +401 -0
  112. package/ciel_runtime_support/managed_mcp_config.py +144 -0
  113. package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
  114. package/ciel_runtime_support/managed_service_cleanup.py +89 -0
  115. package/ciel_runtime_support/mcp_config_reader.py +230 -0
  116. package/ciel_runtime_support/mcp_http_proxy.py +607 -0
  117. package/ciel_runtime_support/mcp_inventory.py +59 -0
  118. package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
  119. package/ciel_runtime_support/mcp_probe_codec.py +136 -0
  120. package/ciel_runtime_support/mcp_probe_transport.py +328 -0
  121. package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
  122. package/ciel_runtime_support/mcp_proxy_config.py +107 -0
  123. package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
  124. package/ciel_runtime_support/mcp_proxy_process.py +560 -0
  125. package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
  126. package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
  127. package/ciel_runtime_support/mcp_transport.py +146 -0
  128. package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
  129. package/ciel_runtime_support/model_catalog_projection.py +61 -0
  130. package/ciel_runtime_support/model_context_hints.py +109 -0
  131. package/ciel_runtime_support/model_panel.py +147 -0
  132. package/ciel_runtime_support/model_registry_repository.py +231 -0
  133. package/ciel_runtime_support/npm_runtime.py +191 -0
  134. package/ciel_runtime_support/ollama_catalog.py +462 -0
  135. package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
  136. package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
  137. package/ciel_runtime_support/ollama_context_sync.py +87 -0
  138. package/ciel_runtime_support/ollama_forwarding.py +449 -0
  139. package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
  140. package/ciel_runtime_support/openai_chat_router.py +64 -0
  141. package/ciel_runtime_support/openai_forwarding.py +194 -0
  142. package/ciel_runtime_support/openai_responses_router.py +291 -0
  143. package/ciel_runtime_support/openai_responses_stream.py +135 -0
  144. package/ciel_runtime_support/output_budget.py +89 -0
  145. package/ciel_runtime_support/package_lifecycle.py +217 -0
  146. package/ciel_runtime_support/plan_artifact_controller.py +104 -0
  147. package/ciel_runtime_support/prelaunch.py +959 -0
  148. package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
  149. package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
  150. package/ciel_runtime_support/prelaunch_terminal.py +764 -0
  151. package/ciel_runtime_support/process_control.py +708 -0
  152. package/ciel_runtime_support/prompt_compaction.py +322 -0
  153. package/ciel_runtime_support/prompt_injection.py +176 -0
  154. package/ciel_runtime_support/protocols/__init__.py +24 -0
  155. package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
  156. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
  157. package/ciel_runtime_support/protocols/chat_projection.py +315 -0
  158. package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
  159. package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
  160. package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
  161. package/ciel_runtime_support/protocols/ollama_response.py +231 -0
  162. package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
  163. package/ciel_runtime_support/protocols/openai_responses.py +271 -0
  164. package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
  165. package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
  166. package/ciel_runtime_support/provider_adapters.py +160 -0
  167. package/ciel_runtime_support/provider_catalog_sources.py +316 -0
  168. package/ciel_runtime_support/provider_choice.py +201 -0
  169. package/ciel_runtime_support/provider_compatibility.py +165 -0
  170. package/ciel_runtime_support/provider_config_mutations.py +361 -0
  171. package/ciel_runtime_support/provider_configuration_service.py +162 -0
  172. package/ciel_runtime_support/provider_context.py +308 -0
  173. package/ciel_runtime_support/provider_contract_projection.py +73 -0
  174. package/ciel_runtime_support/provider_descriptor.py +82 -0
  175. package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
  176. package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
  177. package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
  178. package/ciel_runtime_support/provider_limits.py +457 -0
  179. package/ciel_runtime_support/provider_model_identity.py +139 -0
  180. package/ciel_runtime_support/provider_model_selection.py +431 -0
  181. package/ciel_runtime_support/provider_model_specs.py +142 -0
  182. package/ciel_runtime_support/provider_models.py +263 -0
  183. package/ciel_runtime_support/provider_network.py +176 -0
  184. package/ciel_runtime_support/provider_option_cli.py +238 -0
  185. package/ciel_runtime_support/provider_option_panel.py +275 -0
  186. package/ciel_runtime_support/provider_option_status.py +192 -0
  187. package/ciel_runtime_support/provider_policy.py +101 -0
  188. package/ciel_runtime_support/provider_query_policy.py +67 -0
  189. package/ciel_runtime_support/provider_readiness.py +112 -0
  190. package/ciel_runtime_support/provider_request_access.py +131 -0
  191. package/ciel_runtime_support/provider_request_builder.py +250 -0
  192. package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
  193. package/ciel_runtime_support/provider_runtime_info.py +113 -0
  194. package/ciel_runtime_support/provider_runtime_modes.py +150 -0
  195. package/ciel_runtime_support/provider_sampling_policy.py +46 -0
  196. package/ciel_runtime_support/provider_status.py +145 -0
  197. package/ciel_runtime_support/provider_timeout_policy.py +184 -0
  198. package/ciel_runtime_support/provider_tool_policy.py +145 -0
  199. package/ciel_runtime_support/providers/__init__.py +65 -0
  200. package/ciel_runtime_support/providers/anthropic.py +160 -0
  201. package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
  202. package/ciel_runtime_support/providers/base.py +232 -0
  203. package/ciel_runtime_support/providers/catalog.py +326 -0
  204. package/ciel_runtime_support/providers/cloud.py +194 -0
  205. package/ciel_runtime_support/providers/constants.py +54 -0
  206. package/ciel_runtime_support/providers/deepseek.py +115 -0
  207. package/ciel_runtime_support/providers/fireworks.py +158 -0
  208. package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
  209. package/ciel_runtime_support/providers/kimi.py +297 -0
  210. package/ciel_runtime_support/providers/lm_studio.py +76 -0
  211. package/ciel_runtime_support/providers/meta.py +257 -0
  212. package/ciel_runtime_support/providers/native.py +194 -0
  213. package/ciel_runtime_support/providers/nim.py +69 -0
  214. package/ciel_runtime_support/providers/nvidia.py +158 -0
  215. package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
  216. package/ciel_runtime_support/providers/ollama.py +181 -0
  217. package/ciel_runtime_support/providers/ollama_context.py +195 -0
  218. package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
  219. package/ciel_runtime_support/providers/opencode.py +220 -0
  220. package/ciel_runtime_support/providers/opencode_go.py +37 -0
  221. package/ciel_runtime_support/providers/openrouter.py +57 -0
  222. package/ciel_runtime_support/providers/vllm.py +65 -0
  223. package/ciel_runtime_support/providers/zai.py +119 -0
  224. package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
  225. package/ciel_runtime_support/rate_limit_policy.py +117 -0
  226. package/ciel_runtime_support/rate_limit_repository.py +154 -0
  227. package/ciel_runtime_support/registry.py +46 -0
  228. package/ciel_runtime_support/request_shortcuts.py +253 -0
  229. package/ciel_runtime_support/request_trace.py +323 -0
  230. package/ciel_runtime_support/response_collection.py +209 -0
  231. package/ciel_runtime_support/router_access.py +238 -0
  232. package/ciel_runtime_support/router_client_lifecycle.py +366 -0
  233. package/ciel_runtime_support/router_health_policy.py +101 -0
  234. package/ciel_runtime_support/router_http.py +513 -0
  235. package/ciel_runtime_support/router_process_lifecycle.py +401 -0
  236. package/ciel_runtime_support/router_rate_limit_service.py +285 -0
  237. package/ciel_runtime_support/router_server_runtime.py +103 -0
  238. package/ciel_runtime_support/router_shortcuts.py +201 -0
  239. package/ciel_runtime_support/routing_fallback.py +73 -0
  240. package/ciel_runtime_support/runtime_activity_repository.py +143 -0
  241. package/ciel_runtime_support/runtime_adapters.py +104 -0
  242. package/ciel_runtime_support/runtime_command_factory.py +73 -0
  243. package/ciel_runtime_support/runtime_compatibility.py +50 -0
  244. package/ciel_runtime_support/runtime_constants.py +178 -0
  245. package/ciel_runtime_support/runtime_launch.py +1602 -0
  246. package/ciel_runtime_support/runtime_llm_options.py +312 -0
  247. package/ciel_runtime_support/runtime_logging.py +161 -0
  248. package/ciel_runtime_support/runtime_paths.py +157 -0
  249. package/ciel_runtime_support/runtime_restart.py +84 -0
  250. package/ciel_runtime_support/runtime_upgrade.py +149 -0
  251. package/ciel_runtime_support/secure_json_repository.py +55 -0
  252. package/ciel_runtime_support/session_import.py +356 -0
  253. package/ciel_runtime_support/settings_repository.py +8 -0
  254. package/ciel_runtime_support/slash_command_assets.py +211 -0
  255. package/ciel_runtime_support/sse_stream.py +57 -0
  256. package/ciel_runtime_support/sse_trace.py +225 -0
  257. package/ciel_runtime_support/statusline_script.py +593 -0
  258. package/ciel_runtime_support/statusline_settings.py +53 -0
  259. package/ciel_runtime_support/stream_chunk_policy.py +18 -0
  260. package/ciel_runtime_support/streaming_anthropic.py +1955 -0
  261. package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
  262. package/ciel_runtime_support/terminal_platform_io.py +127 -0
  263. package/ciel_runtime_support/timeout_profile.py +196 -0
  264. package/ciel_runtime_support/tool_dialects.py +85 -0
  265. package/ciel_runtime_support/tool_exposure_policy.py +63 -0
  266. package/ciel_runtime_support/tool_guard_hooks.py +218 -0
  267. package/ciel_runtime_support/tool_request_projection.py +96 -0
  268. package/ciel_runtime_support/tool_schema.py +483 -0
  269. package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
  270. package/ciel_runtime_support/ui_text.py +266 -0
  271. package/ciel_runtime_support/upstream_error_policy.py +104 -0
  272. package/ciel_runtime_support/upstream_retry.py +419 -0
  273. package/ciel_runtime_support/upstream_stream_io.py +106 -0
  274. package/ciel_runtime_support/usage_events.py +96 -0
  275. package/ciel_runtime_support/visible_stream_filters.py +130 -0
  276. package/ciel_runtime_support/web_endpoints.py +447 -0
  277. package/ciel_runtime_support/web_ui.py +915 -0
  278. package/ciel_runtime_support/web_ui_controller.py +189 -0
  279. package/ciel_runtime_support/windows_console_input.py +137 -0
  280. package/ciel_runtime_support/windows_console_mode.py +112 -0
  281. package/docs/Architecture.md +54 -0
  282. package/docs/Configuration.md +17 -0
  283. package/docs/Module-Map.md +1093 -26
  284. package/docs/Providers.md +46 -1
  285. package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
  286. package/npm-bin/run-ciel-runtime.js +22 -2
  287. 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)