digitalkin 1.0.0.dev19__tar.gz → 1.0.0.dev21__tar.gz

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 (232) hide show
  1. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/PKG-INFO +1 -1
  2. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/pyproject.toml +1 -1
  3. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/__version__.py +1 -1
  4. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/community/agno/hitl.py +1 -1
  5. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/exceptions.py +4 -0
  6. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/gateway_servicer.py +139 -78
  7. digitalkin-1.0.0.dev21/src/digitalkin/grpc_servers/interceptors/permission.py +103 -0
  8. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/utils/grpc_client_wrapper.py +2 -1
  9. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/utils/grpc_error_handler.py +4 -1
  10. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/gateway.py +22 -1
  11. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/modules/_base_module.py +35 -0
  12. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/setup/grpc_setup.py +4 -1
  13. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/storage/grpc_storage.py +41 -1
  14. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin.egg-info/PKG-INFO +1 -1
  15. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin.egg-info/SOURCES.txt +1 -0
  16. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/LICENSE +0 -0
  17. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/README.md +0 -0
  18. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/base_server/__init__.py +0 -0
  19. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/base_server/mock/__init__.py +0 -0
  20. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/base_server/mock/mock_pb2.py +0 -0
  21. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/base_server/mock/mock_pb2_grpc.py +0 -0
  22. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/base_server/server_async_insecure.py +0 -0
  23. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/base_server/server_async_secure.py +0 -0
  24. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/base_server/server_sync_insecure.py +0 -0
  25. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/base_server/server_sync_secure.py +0 -0
  26. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/bench_module/__init__.py +0 -0
  27. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/bench_module/echo_module.py +0 -0
  28. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/bench_module/models/__init__.py +0 -0
  29. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/bench_module/models/input.py +0 -0
  30. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/bench_module/models/output.py +0 -0
  31. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/bench_module/models/secret.py +0 -0
  32. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/bench_module/models/setup.py +0 -0
  33. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/bench_module/server.py +0 -0
  34. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/bench_module/triggers/__init__.py +0 -0
  35. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/bench_module/triggers/message_trigger.py +0 -0
  36. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/modules/__init__.py +0 -0
  37. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/modules/archetype_with_tools_module.py +0 -0
  38. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/modules/cpu_intensive_module.py +0 -0
  39. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/modules/dynamic_setup_module.py +0 -0
  40. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/modules/minimal_llm_module.py +0 -0
  41. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/modules/text_transform_module.py +0 -0
  42. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/monitoring/digitalkin_observability/__init__.py +0 -0
  43. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/monitoring/digitalkin_observability/http_server.py +0 -0
  44. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/monitoring/digitalkin_observability/interceptors.py +0 -0
  45. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/monitoring/digitalkin_observability/metrics.py +0 -0
  46. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/monitoring/digitalkin_observability/prometheus.py +0 -0
  47. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/monitoring/tests/test_metrics.py +0 -0
  48. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/redis_demo/client.py +0 -0
  49. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/redis_demo/echo_module.py +0 -0
  50. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/redis_demo/models/__init__.py +0 -0
  51. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/redis_demo/models/input.py +0 -0
  52. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/redis_demo/models/output.py +0 -0
  53. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/redis_demo/models/secret.py +0 -0
  54. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/redis_demo/models/setup.py +0 -0
  55. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/redis_demo/server.py +0 -0
  56. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/redis_demo/triggers/__init__.py +0 -0
  57. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/redis_demo/triggers/message_trigger.py +0 -0
  58. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/services/filesystem_module.py +0 -0
  59. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/examples/services/storage_module.py +0 -0
  60. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/setup.cfg +0 -0
  61. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/__init__.py +0 -0
  62. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/community/__init__.py +0 -0
  63. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/community/agno/__init__.py +0 -0
  64. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/community/agno/agno_adapter.py +0 -0
  65. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/community/agno/agui_tools.py +0 -0
  66. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/community/agno/models.py +0 -0
  67. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/__init__.py +0 -0
  68. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/common/__init__.py +0 -0
  69. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/common/factories.py +0 -0
  70. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/exceptions.py +0 -0
  71. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/job_manager/__init__.py +0 -0
  72. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/job_manager/base_job_manager.py +0 -0
  73. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/job_manager/single_job_manager.py +0 -0
  74. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/profiling/__init__.py +0 -0
  75. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/profiling/step_timer.py +0 -0
  76. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/profiling/task_profiler.py +0 -0
  77. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/resilience/__init__.py +0 -0
  78. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/resilience/bulkhead.py +0 -0
  79. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/resilience/task_supervisor.py +0 -0
  80. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/task_manager/__init__.py +0 -0
  81. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/task_manager/base_task_manager.py +0 -0
  82. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/task_manager/local_task_manager.py +0 -0
  83. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/task_manager/module_runner.py +0 -0
  84. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/task_manager/redis/__init__.py +0 -0
  85. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/task_manager/redis/proto_streams.py +0 -0
  86. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/task_manager/redis/redis_client.py +0 -0
  87. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/task_manager/redis/redis_idempotency.py +0 -0
  88. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/task_manager/redis/redis_signal.py +0 -0
  89. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/task_manager/redis/redis_state.py +0 -0
  90. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/task_manager/remote_task_manager.py +0 -0
  91. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/task_manager/task_executor.py +0 -0
  92. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/core/task_manager/task_session.py +0 -0
  93. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/exceptions.py +0 -0
  94. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/__init__.py +0 -0
  95. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/_base_server.py +0 -0
  96. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/interceptors/__init__.py +0 -0
  97. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/interceptors/request_ids.py +0 -0
  98. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/m2m_call_registry.py +0 -0
  99. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/module_server.py +0 -0
  100. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/module_servicer.py +0 -0
  101. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/stream_registry.py +0 -0
  102. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/stream_session.py +0 -0
  103. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/utils/__init__.py +0 -0
  104. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/utils/circuit_breaker.py +0 -0
  105. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/utils/utility_schema_extender.py +0 -0
  106. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/grpc_servers/utils/validators.py +0 -0
  107. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/logger.py +0 -0
  108. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/mixins/__init__.py +0 -0
  109. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/mixins/agui_mixin.py +0 -0
  110. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/mixins/base_mixin.py +0 -0
  111. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/mixins/cost_mixin.py +0 -0
  112. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/mixins/file_history_mixin.py +0 -0
  113. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/mixins/filesystem_mixin.py +0 -0
  114. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/mixins/logger_mixin.py +0 -0
  115. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/mixins/storage_mixin.py +0 -0
  116. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/__init__.py +0 -0
  117. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/core/__init__.py +0 -0
  118. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/core/job_manager_models.py +0 -0
  119. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/core/redis.py +0 -0
  120. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/core/task_monitor.py +0 -0
  121. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/events/__init__.py +0 -0
  122. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/events/agent_events.py +0 -0
  123. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/grpc_servers/__init__.py +0 -0
  124. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/grpc_servers/circuit_breaker.py +0 -0
  125. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/grpc_servers/m2m.py +0 -0
  126. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/grpc_servers/models.py +0 -0
  127. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/grpc_servers/stream_error_codes.py +0 -0
  128. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/grpc_servers/types.py +0 -0
  129. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/module/__init__.py +0 -0
  130. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/module/ag_ui.py +0 -0
  131. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/module/base_types.py +0 -0
  132. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/module/module.py +0 -0
  133. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/module/module_context.py +0 -0
  134. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/module/module_types.py +0 -0
  135. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/module/request_metadata.py +0 -0
  136. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/module/select_schema.py +0 -0
  137. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/module/setup_types.py +0 -0
  138. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/module/tool_cache.py +0 -0
  139. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/module/tool_reference.py +0 -0
  140. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/module/utility.py +0 -0
  141. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/services/__init__.py +0 -0
  142. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/services/cost.py +0 -0
  143. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/services/registry.py +0 -0
  144. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/services/services.py +0 -0
  145. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/services/storage.py +0 -0
  146. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/__init__.py +0 -0
  147. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/grpc_client.py +0 -0
  148. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/log.py +0 -0
  149. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/module.py +0 -0
  150. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/profiling.py +0 -0
  151. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/queue.py +0 -0
  152. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/redis.py +0 -0
  153. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/resilience.py +0 -0
  154. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/server/__init__.py +0 -0
  155. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/server/channel.py +0 -0
  156. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/server/grpc.py +0 -0
  157. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/server/server.py +0 -0
  158. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/server/servicer.py +0 -0
  159. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/task_manager.py +0 -0
  160. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/utils/__init__.py +0 -0
  161. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/settings/utils/channel.py +0 -0
  162. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/utils/__init__.py +0 -0
  163. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/models/utils/dynamic_schema.py +0 -0
  164. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/modules/__init__.py +0 -0
  165. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/modules/archetype_module.py +0 -0
  166. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/modules/tool_module.py +0 -0
  167. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/modules/trigger_handler.py +0 -0
  168. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/modules/triggers/__init__.py +0 -0
  169. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/modules/triggers/healthcheck_ping_trigger.py +0 -0
  170. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/modules/triggers/healthcheck_services_trigger.py +0 -0
  171. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/modules/triggers/healthcheck_status_trigger.py +0 -0
  172. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/py.typed +0 -0
  173. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/__init__.py +0 -0
  174. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/base_strategy.py +0 -0
  175. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/communication/__init__.py +0 -0
  176. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/communication/communication_strategy.py +0 -0
  177. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/communication/default_communication.py +0 -0
  178. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/communication/exceptions.py +0 -0
  179. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/communication/grpc_communication.py +0 -0
  180. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/cost/__init__.py +0 -0
  181. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/cost/cost_strategy.py +0 -0
  182. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/cost/default_cost.py +0 -0
  183. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/cost/exceptions.py +0 -0
  184. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/cost/grpc_cost.py +0 -0
  185. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/filesystem/__init__.py +0 -0
  186. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/filesystem/default_filesystem.py +0 -0
  187. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/filesystem/exceptions.py +0 -0
  188. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/filesystem/filesystem_strategy.py +0 -0
  189. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/filesystem/grpc_filesystem.py +0 -0
  190. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/identity/__init__.py +0 -0
  191. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/identity/default_identity.py +0 -0
  192. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/identity/identity_strategy.py +0 -0
  193. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/registry/__init__.py +0 -0
  194. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/registry/default_registry.py +0 -0
  195. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/registry/exceptions.py +0 -0
  196. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/registry/grpc_registry.py +0 -0
  197. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/registry/registry_models.py +0 -0
  198. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/registry/registry_strategy.py +0 -0
  199. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/services_config.py +0 -0
  200. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/services_models.py +0 -0
  201. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/setup/__init__.py +0 -0
  202. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/setup/default_setup.py +0 -0
  203. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/setup/exceptions.py +0 -0
  204. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/setup/setup_strategy.py +0 -0
  205. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/storage/__init__.py +0 -0
  206. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/storage/default_storage.py +0 -0
  207. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/storage/exceptions.py +0 -0
  208. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/storage/storage_strategy.py +0 -0
  209. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/task_manager/__init__.py +0 -0
  210. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/task_manager/default_task_manager.py +0 -0
  211. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/task_manager/exceptions.py +0 -0
  212. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/task_manager/redis_task_manager.py +0 -0
  213. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/task_manager/task_manager_strategy.py +0 -0
  214. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/user_profile/__init__.py +0 -0
  215. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/user_profile/default_user_profile.py +0 -0
  216. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/user_profile/exceptions.py +0 -0
  217. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/user_profile/grpc_user_profile.py +0 -0
  218. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/services/user_profile/user_profile_strategy.py +0 -0
  219. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/utils/__init__.py +0 -0
  220. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/utils/arg_parser.py +0 -0
  221. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/utils/conditional_schema.py +0 -0
  222. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/utils/development_mode_action.py +0 -0
  223. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/utils/dynamic_schema.py +0 -0
  224. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/utils/exceptions.py +0 -0
  225. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/utils/llm_ready_schema.py +0 -0
  226. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/utils/package_discover.py +0 -0
  227. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/utils/proto_utils.py +0 -0
  228. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin/utils/schema_splitter.py +0 -0
  229. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin.egg-info/dependency_links.txt +0 -0
  230. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin.egg-info/requires.txt +0 -0
  231. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/src/digitalkin.egg-info/top_level.txt +0 -0
  232. {digitalkin-1.0.0.dev19 → digitalkin-1.0.0.dev21}/tests/test_exceptions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: digitalkin
3
- Version: 1.0.0.dev19
3
+ Version: 1.0.0.dev21
4
4
  Summary: SDK to build kin used in DigitalKin
5
5
  Author-email: "DigitalKin.ai" <contact@digitalkin.ai>
6
6
  License: Attribution-NonCommercial-ShareAlike 4.0 International
@@ -37,7 +37,7 @@
37
37
  "pydantic-settings>=2.14.1",
38
38
  "redis[hiredis]>=7.4.0,<9",
39
39
  ]
40
- version = "1.0.0.dev19"
40
+ version = "1.0.0.dev21"
41
41
 
42
42
  [project.optional-dependencies]
43
43
  performance = [ "uvloop>=0.21" ]
@@ -5,4 +5,4 @@ from importlib.metadata import PackageNotFoundError, version
5
5
  try:
6
6
  __version__ = version("digitalkin")
7
7
  except PackageNotFoundError:
8
- __version__ = "1.0.0.dev19"
8
+ __version__ = "1.0.0.dev21"
@@ -611,7 +611,7 @@ class AgnoHitlRunner:
611
611
  stream: Any,
612
612
  send: Callable[[BaseAgentRunEvent], Coroutine[Any, Any, None]],
613
613
  thread_id: str,
614
- run_output_cls: type,
614
+ run_output_cls: type[RunOutput],
615
615
  ) -> PauseInfo | None:
616
616
  """Drain an Agno stream, forward events, persist on pause.
617
617
 
@@ -19,6 +19,10 @@ class SecurityError(ServerError):
19
19
  """Error related to security configuration."""
20
20
 
21
21
 
22
+ class PermissionDeniedError(ServerError):
23
+ """Remote service rejected the call with gRPC PERMISSION_DENIED."""
24
+
25
+
22
26
  class ServerStateError(ServerError):
23
27
  """Error related to server state (e.g., already started, not started)."""
24
28
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import contextlib
7
7
  import json
8
+ import random
8
9
  import time
9
10
  from datetime import datetime, timezone
10
11
  from typing import TYPE_CHECKING, Any
@@ -34,7 +35,7 @@ from digitalkin.services.communication.exceptions import InvalidConsumerAddressE
34
35
  from digitalkin.services.communication.grpc_communication import GrpcCommunication
35
36
 
36
37
  if TYPE_CHECKING:
37
- from collections.abc import AsyncGenerator, AsyncIterator
38
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable
38
39
 
39
40
  from digitalkin.core.task_manager.module_runner import ModuleRunner
40
41
  from digitalkin.core.task_manager.redis.redis_client import RedisClient
@@ -157,7 +158,7 @@ class GatewayServicer:
157
158
  await self._m2m.stop()
158
159
  await self._registry.shutdown()
159
160
 
160
- async def StartStream( # noqa: C901, PLR0911, PLR0915
161
+ async def StartStream( # noqa: PLR0911, PLR0915
161
162
  self,
162
163
  request: Any,
163
164
  context: grpc.aio.ServicerContext,
@@ -208,13 +209,18 @@ class GatewayServicer:
208
209
  return gateway_pb2.StartStreamResponse(accepted=False, task_id=task_id)
209
210
 
210
211
  if self._registry.get(task_id) is not None:
211
- logger.debug("Dedup: session exists, reusing", extra=log_extra)
212
- return gateway_pb2.StartStreamResponse(accepted=True, task_id=task_id)
212
+ logger.info(
213
+ "[VALIDATE RD1] StartStream refused: a dial is already live for task_id=%s",
214
+ task_id,
215
+ extra=log_extra,
216
+ ) # TODO(validate): remove after prod validation
217
+ return gateway_pb2.StartStreamResponse(accepted=False, task_id=task_id)
213
218
  timer.mark("dedup_check")
214
219
 
215
220
  # Durable at-most-once guard: survives session teardown and spans replicas.
216
- # A re-issued StartStream for an already-run task is accepted but does NOT
217
- # re-dial/re-execute the caller resumes the existing output via Stream/from_seq.
221
+ # Reconnection is server-driven (the live dial-back auto re-dials the same
222
+ # consumer), so a re-issued StartStream for an already-claimed/running task is
223
+ # REFUSED — one task_id maps to exactly one dial.
218
224
  try:
219
225
  claim = await self._idempotency.claim(task_id, SharedRedisListener.PROCESS_ID)
220
226
  except RedisError:
@@ -226,59 +232,13 @@ class GatewayServicer:
226
232
  return gateway_pb2.StartStreamResponse(accepted=False, task_id=task_id)
227
233
  timer.mark("idempotency_claim")
228
234
  if claim is not ClaimResult.CLAIMED:
229
- # Re-issued StartStream for an already-claimed task. The prior dial-back
230
- # is dead (the dedup branch above returned if its session were still live),
231
- # so re-dial in resume mode to re-attach the restarted consumer from its
232
- # cursor — as long as the durable stream is still retained.
233
- try:
234
- stream_len = await self._redis_client.xlen(f"task:{task_id}:stream")
235
- except RedisError:
236
- logger.warning(
237
- "[VALIDATE R2] StartStream xlen Redis failure → accepted=False",
238
- extra=log_extra,
239
- exc_info=True,
240
- ) # TODO(validate): remove after prod validation
241
- return gateway_pb2.StartStreamResponse(accepted=False, task_id=task_id)
242
- if stream_len == 0:
243
- logger.info(
244
- "[VALIDATE IDEM1] no live stream to resume (TTL expired) task_id=%s (%s)",
245
- task_id,
246
- claim.name,
247
- extra=log_extra,
248
- ) # TODO(validate): remove after prod validation
249
- return gateway_pb2.StartStreamResponse(accepted=False, task_id=task_id)
250
- # Atomic guard: recheck + register + spawn with no await that yields
251
- # between them, so a concurrent re-poke falls into the dedup branch and acks.
252
- if self._registry.get(task_id) is not None:
253
- logger.debug("resume-dial already scheduled, reusing", extra=log_extra)
254
- return gateway_pb2.StartStreamResponse(accepted=True, task_id=task_id)
255
- session = StreamSession(task_id=task_id)
256
- registered = await self._registry.register(
257
- session,
258
- setup_id=request.setup_id,
259
- mission_id=request.mission_id,
260
- )
261
- if not registered:
262
- logger.warning("Resume-dial rejected (capacity)", extra=log_extra)
263
- return gateway_pb2.StartStreamResponse(accepted=False, task_id=task_id)
264
235
  logger.info(
265
- "[VALIDATE IDEM1] resume-dial scheduled task_id=%s (%s) stream_len=%d",
236
+ "[VALIDATE RD1] StartStream refused: task already claimed/running task_id=%s (%s)",
266
237
  task_id,
267
238
  claim.name,
268
- stream_len,
269
239
  extra=log_extra,
270
240
  ) # TODO(validate): remove after prod validation
271
- self._spawn(
272
- self._dial_consumer(
273
- task_id=task_id,
274
- mission_id=request.mission_id,
275
- setup_id=request.setup_id,
276
- address=client_address,
277
- resume=True,
278
- ),
279
- name=f"dial_consumer_{task_id}",
280
- )
281
- return gateway_pb2.StartStreamResponse(accepted=True, task_id=task_id)
241
+ return gateway_pb2.StartStreamResponse(accepted=False, task_id=task_id)
282
242
 
283
243
  session = StreamSession(task_id=task_id)
284
244
  accepted = await self._registry.register(
@@ -763,35 +723,133 @@ class GatewayServicer:
763
723
  return
764
724
  yield resp
765
725
 
766
- async def _dial_consumer( # noqa: C901, PLR0912, PLR0914, PLR0915
726
+ async def _dial_consumer( # noqa: C901
767
727
  self,
768
728
  task_id: str,
769
729
  mission_id: str,
770
730
  setup_id: str,
771
731
  address: str,
772
- *,
773
- resume: bool = False,
774
732
  ) -> None:
775
- """Dial the consumer's GatewayService.Stream and run the BiDi.
776
-
777
- Fresh dial: sends ``stream.init``, then on the consumer's first reply
778
- (the query) invokes the ``ModuleRunner`` and drains module outputs.
779
- Resume dial: evicts any stale channel, sends ``stream.resume``, reads
780
- the consumer's cursor from the first reply's ``from_seq``, skips the
781
- runner (module already running/done), and drains from that cursor.
782
- Channel is pooled by ``GrpcClientWrapper``.
733
+ """Dial the consumer's GatewayService.Stream, with server-side auto-reconnect.
734
+
735
+ First attempt is a fresh dial (``stream.init`` consumer query
736
+ ``ModuleRunner`` → drain from seq 0). If the BiDi dies before the stream
737
+ is fully delivered and the module is still producing, re-dial the SAME
738
+ address in resume mode (``stream.resume`` consumer replies with its
739
+ ``from_seq`` drain from that cursor, deduping the Redis queue) with
740
+ jittered backoff until the client returns or the reconnect window
741
+ (``DIGITALKIN_GATEWAY_DIAL_BACK_RECONNECT_WINDOW_S``) elapses. The module
742
+ runs as a separate task, spawned exactly once, so it keeps writing to
743
+ Redis across reconnects.
783
744
 
784
745
  Args:
785
746
  task_id: Task to push.
786
747
  mission_id: Mission ID (logging context).
787
748
  setup_id: Setup ID (logging context).
788
749
  address: ``host:port`` of the consumer's GatewayService.
789
- resume: Re-attach to an existing task's output instead of starting it.
790
750
  """
791
751
  log_extra = {"task_id": task_id, "setup_id": setup_id, "mission_id": mission_id}
792
752
  # Bind IDs so the dial-back BiDi + logs carry them (task-local spawned context).
793
753
  RequestContext.bind(task_id=task_id, setup_id=setup_id, mission_id=mission_id)
794
754
 
755
+ module_spawned = False
756
+
757
+ def _mark_spawned() -> None:
758
+ nonlocal module_spawned
759
+ module_spawned = True
760
+
761
+ reconnect = get_gateway_settings().dial_reconnect
762
+ deadline: float | None = None
763
+ attempt = 0
764
+ resume = False
765
+ try:
766
+ while True:
767
+ disconnected = await self._run_dial_attempt(
768
+ task_id=task_id,
769
+ mission_id=mission_id,
770
+ setup_id=setup_id,
771
+ address=address,
772
+ resume=resume,
773
+ on_runner_spawn=_mark_spawned,
774
+ )
775
+ if not disconnected:
776
+ break
777
+ # Client BiDi died mid-stream. Re-dial only while there is a
778
+ # retained stream to resume and the reboot window hasn't elapsed.
779
+ if not module_spawned:
780
+ break
781
+ session = self._registry.get(task_id)
782
+ if session is None or session._stop_event.is_set(): # noqa: SLF001
783
+ break
784
+ try:
785
+ stream_len = await self._redis_client.xlen(f"task:{task_id}:stream")
786
+ except RedisError:
787
+ break
788
+ if stream_len == 0:
789
+ break
790
+ now = time.monotonic()
791
+ if deadline is None:
792
+ deadline = now + reconnect.window_s
793
+ if now >= deadline:
794
+ logger.info(
795
+ "[VALIDATE RD1] dial-back gave up after %.0fs reconnect window: task_id=%s attempts=%d",
796
+ reconnect.window_s,
797
+ task_id,
798
+ attempt,
799
+ ) # TODO(validate): remove after prod validation
800
+ break
801
+ attempt += 1
802
+ delay = min(random.uniform(reconnect.backoff_base_s, reconnect.backoff_max_s), deadline - now) # noqa: S311
803
+ logger.info(
804
+ "[VALIDATE RD1] dial-back disconnected — re-dial attempt %d in %.1fs (resume): task_id=%s",
805
+ attempt,
806
+ delay,
807
+ task_id,
808
+ ) # TODO(validate): remove after prod validation
809
+ await asyncio.sleep(max(0.0, delay))
810
+ resume = True
811
+ finally:
812
+ # End-of-stream cleanup (once). Output stream is left intact for replay.
813
+ try:
814
+ removed = await self._registry.unregister(task_id)
815
+ if removed is not None:
816
+ await removed.teardown()
817
+ except Exception:
818
+ logger.exception("end-of-stream unregister failed", extra=log_extra)
819
+
820
+ async def _run_dial_attempt( # noqa: C901, PLR0912, PLR0914, PLR0915
821
+ self,
822
+ *,
823
+ task_id: str,
824
+ mission_id: str,
825
+ setup_id: str,
826
+ address: str,
827
+ resume: bool,
828
+ on_runner_spawn: Callable[[], None],
829
+ ) -> bool:
830
+ """Run one dial-back BiDi attempt (fresh or resume).
831
+
832
+ Fresh (``resume=False``): sends ``stream.init``, spawns the
833
+ ``ModuleRunner`` on the consumer's first reply (calling
834
+ ``on_runner_spawn``), drains from seq 0. Resume: sends ``stream.resume``,
835
+ reads the consumer's cursor from the first reply's ``from_seq``, skips
836
+ the runner, drains from that cursor. Releases the channel before
837
+ returning; does NOT unregister the session (the caller owns lifecycle).
838
+
839
+ Args:
840
+ task_id: Task to push.
841
+ mission_id: Mission ID (logging context).
842
+ setup_id: Setup ID (logging context).
843
+ address: ``host:port`` of the consumer's GatewayService.
844
+ resume: Re-attach to an existing task's output instead of starting it.
845
+ on_runner_spawn: Called once, when this attempt spawns the module runner.
846
+
847
+ Returns:
848
+ True if the BiDi died before delivering ``stream.end`` (a re-dial
849
+ candidate); False on clean completion or a terminal failure.
850
+ """
851
+ log_extra = {"task_id": task_id, "setup_id": setup_id, "mission_id": mission_id}
852
+
795
853
  async def _fail(code: str, message: str) -> None:
796
854
  """Emit a fatal to Redis (fresh dial) or log only (resume).
797
855
 
@@ -816,7 +874,7 @@ class GatewayServicer:
816
874
  StreamErrorCode.DIAL_BACK_INTERNAL.value,
817
875
  "gateway has no client_config to dial back with",
818
876
  )
819
- return
877
+ return False
820
878
  # setup_version_id is unused on the dial-back path.
821
879
  comm = GrpcCommunication(
822
880
  mission_id=mission_id,
@@ -838,14 +896,14 @@ class GatewayServicer:
838
896
  StreamErrorCode.DIAL_BACK_UNREACHABLE.value,
839
897
  f"dial-back channel build failed: {exc}",
840
898
  )
841
- return
899
+ return False
842
900
  except OSError as exc:
843
901
  logger.exception("dial_consumer: channel build failed addr=%s", address, extra=log_extra)
844
902
  await _fail(
845
903
  StreamErrorCode.DIAL_BACK_UNREACHABLE.value,
846
904
  f"dial-back channel build failed: {type(exc).__name__}: {exc}",
847
905
  )
848
- return
906
+ return False
849
907
  t_stub = time.perf_counter_ns()
850
908
  logger.info("→ Dial-back channel ready to %s", address, extra=log_extra)
851
909
 
@@ -878,7 +936,7 @@ class GatewayServicer:
878
936
  extra=log_extra,
879
937
  )
880
938
  await release()
881
- return
939
+ return False
882
940
 
883
941
  # Outbound is StreamServer, inbound is StreamClient — both share
884
942
  # field tags so re-wrapping ``_consume_from_redis`` output is a rename.
@@ -893,8 +951,11 @@ class GatewayServicer:
893
951
  outgoing_done = asyncio.Event()
894
952
  # Consumer's resume cursor, captured from the first reply's ``from_seq``.
895
953
  resume_cursor = 0
954
+ # Set once the reader reaches EOS and stream.end is delivered to the consumer.
955
+ delivered_eos = False
896
956
 
897
957
  async def _outgoing() -> AsyncGenerator:
958
+ nonlocal delivered_eos
898
959
  try:
899
960
  yield init_server
900
961
  logger.info(
@@ -915,6 +976,7 @@ class GatewayServicer:
915
976
  try:
916
977
  cli_msg = await asyncio.wait_for(anext(reader_iter), timeout=idle_timeout)
917
978
  except StopAsyncIteration:
979
+ delivered_eos = True
918
980
  break
919
981
  except asyncio.TimeoutError:
920
982
  logger.warning(
@@ -957,6 +1019,7 @@ class GatewayServicer:
957
1019
  async def _runner_fatal(code: str, message: str) -> None:
958
1020
  await self._emit_fatal_to_redis(task_id, code=code, message=message, log_extra=log_extra)
959
1021
 
1022
+ retriable = False
960
1023
  t_pre_stream = time.perf_counter_ns()
961
1024
  logger.info(
962
1025
  "[dial-debug] pre_stream dt_since_ready=%.3fms ch_state=%s",
@@ -1030,7 +1093,7 @@ class GatewayServicer:
1030
1093
  "gateway has no ModuleRunner configured",
1031
1094
  )
1032
1095
  output_started.set()
1033
- return
1096
+ return False
1034
1097
  self._spawn(
1035
1098
  self._module_runner.run(
1036
1099
  upstream.data,
@@ -1041,6 +1104,7 @@ class GatewayServicer:
1041
1104
  ),
1042
1105
  name=f"module_runner_{task_id}",
1043
1106
  )
1107
+ on_runner_spawn()
1044
1108
  output_started.set()
1045
1109
  first = False
1046
1110
  continue
@@ -1082,6 +1146,8 @@ class GatewayServicer:
1082
1146
  )
1083
1147
  # Suppress DIAL_BACK_NO_QUERY in finally — RPC error already emitted.
1084
1148
  output_started.set()
1149
+ # Client BiDi died before the stream finished → re-dial candidate.
1150
+ retriable = not delivered_eos
1085
1151
  except _GrpcUsageError:
1086
1152
  t_fail = time.perf_counter_ns()
1087
1153
  logger.warning(
@@ -1098,6 +1164,7 @@ class GatewayServicer:
1098
1164
  "dial-back channel closed before BiDi could start",
1099
1165
  )
1100
1166
  output_started.set()
1167
+ retriable = not delivered_eos
1101
1168
  except (RuntimeError, AssertionError, ValueError):
1102
1169
  logger.exception("dial_consumer unexpected error", extra=log_extra)
1103
1170
  if not output_started.is_set():
@@ -1120,10 +1187,4 @@ class GatewayServicer:
1120
1187
  # Unblock _outgoing if consumer never replied.
1121
1188
  output_started.set()
1122
1189
  await release()
1123
- # End-of-stream cleanup; output stream is left intact for replay.
1124
- try:
1125
- removed = await self._registry.unregister(task_id)
1126
- if removed is not None:
1127
- await removed.teardown()
1128
- except Exception:
1129
- logger.exception("end-of-stream unregister failed", extra=log_extra)
1190
+ return retriable
@@ -0,0 +1,103 @@
1
+ """Client-side permission middleware for gRPC service-data access.
2
+
3
+ A single cross-cutting interceptor: any unary call a backend rejects with
4
+ ``PERMISSION_DENIED`` surfaces as :class:`PermissionDeniedError`, so callers
5
+ never handle permission per service. Attached on every channel, it covers all
6
+ data services and module-to-module calls uniformly.
7
+
8
+ The interceptor *returns* a terminal denied call rather than raising: raising a
9
+ non-``AioRpcError`` from an aio interceptor leaks into the intercepted call's
10
+ ``__del__`` (grpc only swallows ``AioRpcError``/``CancelledError`` there), so we
11
+ mirror grpc's own ``UnaryUnaryCallResponse`` pattern and raise from ``__await__``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ import grpc
19
+ import grpc.aio
20
+
21
+ from digitalkin.grpc_servers.exceptions import PermissionDeniedError
22
+ from digitalkin.logger import logger
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Awaitable, Callable
26
+
27
+
28
+ class _DeniedUnaryUnaryCall(grpc.aio.UnaryUnaryCall):
29
+ """A finished unary call that raises ``PermissionDeniedError`` when awaited."""
30
+
31
+ def __init__(self, method: str, details: str) -> None:
32
+ self._message = f"[{method}] {details or 'permission denied'}"
33
+ self._details = details
34
+
35
+ def cancel(self) -> bool: # noqa: PLR6301
36
+ return False
37
+
38
+ def cancelled(self) -> bool: # noqa: PLR6301
39
+ return False
40
+
41
+ def done(self) -> bool: # noqa: PLR6301
42
+ return True
43
+
44
+ def add_done_callback(self, unused_callback: Any) -> None:
45
+ """No-op: the call is already finished.
46
+
47
+ Args:
48
+ unused_callback: Ignored; present to satisfy the call interface.
49
+ """
50
+
51
+ def time_remaining(self) -> float | None: # noqa: PLR6301
52
+ return None
53
+
54
+ async def initial_metadata(self) -> grpc.aio.Metadata: # noqa: PLR6301
55
+ return grpc.aio.Metadata()
56
+
57
+ async def trailing_metadata(self) -> grpc.aio.Metadata: # noqa: PLR6301
58
+ return grpc.aio.Metadata()
59
+
60
+ async def code(self) -> grpc.StatusCode: # noqa: PLR6301
61
+ return grpc.StatusCode.PERMISSION_DENIED
62
+
63
+ async def details(self) -> str:
64
+ return self._details
65
+
66
+ async def debug_error_string(self) -> None: # noqa: PLR6301
67
+ return None
68
+
69
+ async def wait_for_connection(self) -> None:
70
+ """No-op: the call already resolved to a permission denial."""
71
+
72
+ def __await__(self) -> Any:
73
+ raise PermissionDeniedError(self._message)
74
+ yield # unreachable; marks this method a generator so it is awaitable
75
+
76
+
77
+ class PermissionClientInterceptor(grpc.aio.UnaryUnaryClientInterceptor):
78
+ """Map a ``PERMISSION_DENIED`` unary reply to ``PermissionDeniedError``."""
79
+
80
+ async def intercept_unary_unary( # noqa: PLR6301
81
+ self,
82
+ continuation: Callable[[grpc.aio.ClientCallDetails, Any], Awaitable[Any]],
83
+ client_call_details: grpc.aio.ClientCallDetails,
84
+ request: Any,
85
+ ) -> Any:
86
+ """Return a denied call on PERMISSION_DENIED, else pass the real call through.
87
+
88
+ Args:
89
+ continuation: Downstream call continuation.
90
+ client_call_details: Original call details.
91
+ request: Request message.
92
+
93
+ Returns:
94
+ The downstream call, or a terminal call raising PermissionDeniedError on await.
95
+ """
96
+ call = await continuation(client_call_details, request)
97
+ if await call.code() == grpc.StatusCode.PERMISSION_DENIED:
98
+ method = client_call_details.method
99
+ method_name = method.decode() if isinstance(method, bytes) else method
100
+ details = await call.details()
101
+ logger.warning("permission denied on %s: %s", method_name, details or "")
102
+ return _DeniedUnaryUnaryCall(method_name, details)
103
+ return call
@@ -16,6 +16,7 @@ import grpc.aio
16
16
 
17
17
  from digitalkin.core.resilience.bulkhead import Bulkhead
18
18
  from digitalkin.grpc_servers.exceptions import CircuitOpenError, ServerError
19
+ from digitalkin.grpc_servers.interceptors.permission import PermissionClientInterceptor
19
20
  from digitalkin.grpc_servers.interceptors.request_ids import RequestIdClientInterceptor
20
21
  from digitalkin.grpc_servers.utils.circuit_breaker import CircuitBreaker
21
22
  from digitalkin.logger import logger
@@ -103,7 +104,7 @@ class GrpcClientWrapper:
103
104
 
104
105
  credentials = self._build_channel_credentials(config)
105
106
  grpc_compression = config.compression.to_grpc()
106
- interceptors = [RequestIdClientInterceptor()]
107
+ interceptors = [RequestIdClientInterceptor(), PermissionClientInterceptor()]
107
108
  if credentials is not None:
108
109
  channel = grpc.aio.secure_channel(
109
110
  config.address,
@@ -4,7 +4,7 @@ from collections.abc import AsyncGenerator
4
4
  from contextlib import asynccontextmanager
5
5
  from typing import Any
6
6
 
7
- from digitalkin.grpc_servers.exceptions import ServerError
7
+ from digitalkin.grpc_servers.exceptions import PermissionDeniedError, ServerError
8
8
  from digitalkin.logger import logger
9
9
 
10
10
 
@@ -28,6 +28,7 @@ class GrpcErrorHandlerMixin:
28
28
  Context for the operation.
29
29
 
30
30
  Raises:
31
+ PermissionDeniedError: Re-raised as-is so the authz status is never masked.
31
32
  ServerError: For gRPC-related errors.
32
33
  service_error_class: For service-specific errors if provided.
33
34
  """
@@ -36,6 +37,8 @@ class GrpcErrorHandlerMixin:
36
37
 
37
38
  try:
38
39
  yield
40
+ except PermissionDeniedError:
41
+ raise
39
42
  except service_error_class as e:
40
43
  # Re-raise service-specific errors as-is
41
44
  msg = f"{service_error_class.__name__} in {operation}: {e}"
@@ -11,7 +11,10 @@ class GatewayStreamSettings(BaseSettings):
11
11
 
12
12
  model_config = SettingsConfigDict(env_prefix="DIGITALKIN_GATEWAY_STREAM_", case_sensitive=False)
13
13
 
14
- redis_stream_ttl: int = Field(default=60, description="Stream TTL in seconds after EOS")
14
+ redis_stream_ttl: int = Field(
15
+ default=360,
16
+ description="Stream TTL (s) after EOS; >= reconnect window so a completed stream survives a reboot",
17
+ )
15
18
  redis_stream_initial_ttl: int = Field(default=600, description="Stream TTL in seconds before EOS")
16
19
  redis_stream_maxlen: int = Field(default=1000, gt=0, description="Approximate max entries before trimming")
17
20
  redis_cursor_ttl: int = Field(default=360, description="Cursor key TTL in seconds")
@@ -113,6 +116,23 @@ class GatewayQueueSettings(BaseSettings):
113
116
  )
114
117
 
115
118
 
119
+ class GatewayDialReconnectSettings(BaseSettings):
120
+ """Server-side dial-back auto-reconnect window and backoff."""
121
+
122
+ model_config = SettingsConfigDict(env_prefix="DIGITALKIN_GATEWAY_DIAL_BACK_RECONNECT_", case_sensitive=False)
123
+
124
+ window_s: float = Field(
125
+ default=300.0,
126
+ description=(
127
+ "Total time, measured from the first detected disconnect, that the gateway keeps "
128
+ "re-dialing a dead consumer so a client that lost internet or rebooted can re-attach "
129
+ "and resume from its cursor. Keep <= the post-EOS stream TTL."
130
+ ),
131
+ )
132
+ backoff_base_s: float = Field(default=3.0, description="Base delay between re-dial attempts (full-jittered).")
133
+ backoff_max_s: float = Field(default=5.0, description="Cap on the re-dial backoff delay.")
134
+
135
+
116
136
  class GatewaySettings(BaseSettings):
117
137
  """Top-level gateway configuration."""
118
138
 
@@ -149,6 +169,7 @@ class GatewaySettings(BaseSettings):
149
169
  stream: GatewayStreamSettings = Field(default_factory=GatewayStreamSettings)
150
170
  queue: GatewayQueueSettings = Field(default_factory=GatewayQueueSettings)
151
171
  m2m: GatewayM2MSettings = Field(default_factory=GatewayM2MSettings)
172
+ dial_reconnect: GatewayDialReconnectSettings = Field(default_factory=GatewayDialReconnectSettings)
152
173
 
153
174
 
154
175
  @lru_cache(maxsize=1)
@@ -7,6 +7,7 @@ from abc import ABC, abstractmethod
7
7
  from collections.abc import Callable, Coroutine
8
8
  from typing import Any, ClassVar, Generic
9
9
 
10
+ from digitalkin.grpc_servers.exceptions import PermissionDeniedError
10
11
  from digitalkin.grpc_servers.utils.utility_schema_extender import UtilitySchemaExtender
11
12
  from digitalkin.logger import logger
12
13
  from digitalkin.models.module.module import ModuleCodeModel, ModuleStatus
@@ -548,6 +549,10 @@ class BaseModule( # Module SDK base class requires many public methods # noqa:
548
549
  self._status = ModuleStatus.CANCELLED
549
550
  logger.info("Module %s cancelled", self.name, extra=self.context.session.current_ids())
550
551
  raise
552
+ except PermissionDeniedError as e:
553
+ self._status = ModuleStatus.FAILED
554
+ logger.warning("Permission denied in module %s: %s", self.name, e, extra=self.context.session.current_ids())
555
+ await self._notify_permission_denied(self.context.callbacks.send_message, e)
551
556
  except Exception as e:
552
557
  self._status = ModuleStatus.FAILED
553
558
  logger.exception("Error inside module %s", self.name, extra=self.context.session.current_ids())
@@ -564,6 +569,28 @@ class BaseModule( # Module SDK base class requires many public methods # noqa:
564
569
  else:
565
570
  self._status = ModuleStatus.STOPPING
566
571
 
572
+ async def _notify_permission_denied(
573
+ self,
574
+ callback: Callable[..., Coroutine[Any, Any, None]],
575
+ e: PermissionDeniedError,
576
+ ) -> None:
577
+ """Send a PermissionDenied notification to the user via the callback.
578
+
579
+ Args:
580
+ callback: The output callback installed on the module context.
581
+ e: The permission-denied error raised by a service call.
582
+ """
583
+ try:
584
+ await callback(
585
+ ModuleCodeModel(
586
+ code="PermissionDenied",
587
+ short_description="Permission denied",
588
+ message=str(e),
589
+ )
590
+ )
591
+ except Exception:
592
+ logger.exception("Failed to send permission-denied callback", extra=self.context.session.current_ids())
593
+
567
594
  async def prepare(
568
595
  self,
569
596
  setup_data: SetupModelT,
@@ -623,6 +650,14 @@ class BaseModule( # Module SDK base class requires many public methods # noqa:
623
650
  try:
624
651
  await self.prepare(setup_data, callback)
625
652
  timer.mark("prepare")
653
+ except PermissionDeniedError as e:
654
+ self._status = ModuleStatus.FAILED
655
+ logger.warning("Permission denied initializing module: %s", e, extra=self.context.session.current_ids())
656
+ await self._notify_permission_denied(callback, e)
657
+ if done_callback is not None:
658
+ await done_callback(None)
659
+ await self.stop()
660
+ return
626
661
  except Exception as e:
627
662
  self._status = ModuleStatus.FAILED
628
663
  short_description = "Error initializing module"