microcoreos 0.2.2__tar.gz → 0.3.1__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 (210) hide show
  1. microcoreos-0.3.1/.env.example +191 -0
  2. {microcoreos-0.2.2 → microcoreos-0.3.1}/.gitignore +7 -0
  3. {microcoreos-0.2.2 → microcoreos-0.3.1}/AI_CONTEXT.md +42 -3
  4. {microcoreos-0.2.2 → microcoreos-0.3.1}/PKG-INFO +2 -1
  5. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_tools/auth/auth_tool.py +22 -3
  6. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_tools/scheduler/scheduler_tool.py +114 -1
  7. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos/catalog.py +44 -2
  8. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos/scaffold.py +57 -1
  9. {microcoreos-0.2.2 → microcoreos-0.3.1}/pyproject.toml +18 -1
  10. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_auth_tool.py +33 -0
  11. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_catalog.py +122 -0
  12. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_context_tool.py +77 -2
  13. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_core.py +137 -0
  14. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_core_purity.py +1 -1
  15. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_event_bus_tool.py +60 -0
  16. microcoreos-0.3.1/tests/test_http_pipeline.py +222 -0
  17. microcoreos-0.3.1/tests/test_http_server_tool.py +527 -0
  18. microcoreos-0.3.1/tests/test_kernel.py +348 -0
  19. microcoreos-0.3.1/tests/test_scheduler_tool.py +215 -0
  20. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_sqlite_tool.py +5 -0
  21. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_tool_proxy.py +12 -1
  22. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/tools/test_event_bus_broker_parity.py +1 -1
  23. microcoreos-0.3.1/tests/tools/test_registry_tool.py +52 -0
  24. microcoreos-0.3.1/tests/tools/test_telemetry_tool.py +247 -0
  25. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/event_bus/sqlite_driver.py +7 -0
  26. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/http_server/http_server_tool.py +207 -22
  27. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/http_server/pipeline.py +29 -1
  28. microcoreos-0.3.1/tools/http_server/types.py +100 -0
  29. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/sqlite/sqlite_tool.py +18 -16
  30. {microcoreos-0.2.2 → microcoreos-0.3.1}/uv.lock +435 -1
  31. microcoreos-0.2.2/.env.example +0 -96
  32. microcoreos-0.2.2/tests/test_http_server_tool.py +0 -151
  33. microcoreos-0.2.2/tests/test_kernel.py +0 -131
  34. microcoreos-0.2.2/tests/test_scheduler_tool.py +0 -96
  35. {microcoreos-0.2.2 → microcoreos-0.3.1}/.agent/skills/microcoreos-architecture/SKILL.md +0 -0
  36. {microcoreos-0.2.2 → microcoreos-0.3.1}/.agent/skills/microcoreos-architecture/agent.md +0 -0
  37. {microcoreos-0.2.2 → microcoreos-0.3.1}/.agent/workflows/feature-plan.md +0 -0
  38. {microcoreos-0.2.2 → microcoreos-0.3.1}/.agent/workflows/multi-domain-plan.md +0 -0
  39. {microcoreos-0.2.2 → microcoreos-0.3.1}/.agent/workflows/new-domain.md +0 -0
  40. {microcoreos-0.2.2 → microcoreos-0.3.1}/.agent/workflows/new-tool.md +0 -0
  41. {microcoreos-0.2.2 → microcoreos-0.3.1}/.claude/settings.local.json +0 -0
  42. {microcoreos-0.2.2 → microcoreos-0.3.1}/.dockerignore +0 -0
  43. {microcoreos-0.2.2 → microcoreos-0.3.1}/.github/workflows/ci.yml +0 -0
  44. {microcoreos-0.2.2 → microcoreos-0.3.1}/.github/workflows/release.yml +0 -0
  45. {microcoreos-0.2.2 → microcoreos-0.3.1}/.python-version +0 -0
  46. {microcoreos-0.2.2 → microcoreos-0.3.1}/AGENTS.md +0 -0
  47. {microcoreos-0.2.2 → microcoreos-0.3.1}/Dockerfile +0 -0
  48. {microcoreos-0.2.2 → microcoreos-0.3.1}/INSTRUCTIONS_FOR_AI.md +0 -0
  49. {microcoreos-0.2.2 → microcoreos-0.3.1}/LICENSE +0 -0
  50. {microcoreos-0.2.2 → microcoreos-0.3.1}/README.md +0 -0
  51. {microcoreos-0.2.2 → microcoreos-0.3.1}/ROADMAP.md +0 -0
  52. {microcoreos-0.2.2 → microcoreos-0.3.1}/cli.py +0 -0
  53. {microcoreos-0.2.2 → microcoreos-0.3.1}/dev_infra/cache_probe.py +0 -0
  54. {microcoreos-0.2.2 → microcoreos-0.3.1}/dev_infra/docker-compose.yml +0 -0
  55. {microcoreos-0.2.2 → microcoreos-0.3.1}/docs/CLI.md +0 -0
  56. {microcoreos-0.2.2 → microcoreos-0.3.1}/docs/CORE_INFRASTRUCTURE.md +0 -0
  57. {microcoreos-0.2.2 → microcoreos-0.3.1}/docs/ELASTIC_DEPLOYMENT.md +0 -0
  58. {microcoreos-0.2.2 → microcoreos-0.3.1}/docs/EVENT_BUS.md +0 -0
  59. {microcoreos-0.2.2 → microcoreos-0.3.1}/docs/HTTP_SERVER.md +0 -0
  60. {microcoreos-0.2.2 → microcoreos-0.3.1}/docs/INDEX.md +0 -0
  61. {microcoreos-0.2.2 → microcoreos-0.3.1}/docs/OBSERVABILITY.md +0 -0
  62. {microcoreos-0.2.2 → microcoreos-0.3.1}/docs/OBSERVABILITY_API.md +0 -0
  63. {microcoreos-0.2.2 → microcoreos-0.3.1}/docs/PARALLEL_DEVELOPMENT.md +0 -0
  64. {microcoreos-0.2.2 → microcoreos-0.3.1}/docs/translations/es/README.md +0 -0
  65. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/devtools/lint/plugin_sources.py +0 -0
  66. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/devtools/plugins/discovery_naming_linter_plugin.py +0 -0
  67. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/devtools/plugins/domain_isolation_linter_plugin.py +0 -0
  68. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/devtools/plugins/event_contract_linter_plugin.py +0 -0
  69. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/devtools/plugins/event_schemas_plugin.py +0 -0
  70. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/devtools/plugins/field_divergence_linter_plugin.py +0 -0
  71. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/devtools/plugins/route_collision_linter_plugin.py +0 -0
  72. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/devtools/plugins/table_ownership_linter_plugin.py +0 -0
  73. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/devtools/plugins/tool_doc_drift_linter_plugin.py +0 -0
  74. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/system/plugins/event_delivery_monitor_plugin.py +0 -0
  75. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/system/plugins/system_events_plugin.py +0 -0
  76. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/system/plugins/system_events_stream_plugin.py +0 -0
  77. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/system/plugins/system_logs_stream_plugin.py +0 -0
  78. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/system/plugins/system_metrics_plugin.py +0 -0
  79. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/system/plugins/system_status_plugin.py +0 -0
  80. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/system/plugins/system_traces_plugin.py +0 -0
  81. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/system/plugins/system_traces_stream_plugin.py +0 -0
  82. {microcoreos-0.2.2 → microcoreos-0.3.1}/domains/system/plugins/tool_health_plugin.py +0 -0
  83. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/chaos/plugins/blocking_boot_plugin.py +0 -0
  84. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/chaos/plugins/chaos_control_plugin.py +0 -0
  85. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/chaos/plugins/failing_plugin.py +0 -0
  86. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/chaos/plugins/stress_plugin.py +0 -0
  87. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/ping/plugins/ping_plugin.py +0 -0
  88. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/scheduler/migrations/001_scheduler_one_shots.sql +0 -0
  89. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/scheduler/models/scheduler_one_shot.py +0 -0
  90. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/scheduler/plugins/durable_one_shots_plugin.py +0 -0
  91. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/users/migrations/001_create_users.sql +0 -0
  92. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/users/models/user.py +0 -0
  93. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/users/plugins/create_user_plugin.py +0 -0
  94. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/users/plugins/delete_user_plugin.py +0 -0
  95. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/users/plugins/get_me_plugin.py +0 -0
  96. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/users/plugins/get_user_by_id_plugin.py +0 -0
  97. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/users/plugins/get_users_plugin.py +0 -0
  98. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/users/plugins/login_plugin.py +0 -0
  99. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/users/plugins/logout_plugin.py +0 -0
  100. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/users/plugins/update_user_plugin.py +0 -0
  101. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_domains/users/plugins/welcome_service_plugin.py +0 -0
  102. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_tools/chaos/chaos_tool.py +0 -0
  103. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_tools/kafka/kafka_driver.py +0 -0
  104. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_tools/postgresql/postgresql_tool.py +0 -0
  105. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_tools/rabbitmq/rabbitmq_driver.py +0 -0
  106. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_tools/redis_state/redis_state_tool.py +0 -0
  107. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_tools/s3/__init__.py +0 -0
  108. {microcoreos-0.2.2 → microcoreos-0.3.1}/extras/available_tools/s3/s3_tool.py +0 -0
  109. {microcoreos-0.2.2 → microcoreos-0.3.1}/hatch_build.py +0 -0
  110. {microcoreos-0.2.2 → microcoreos-0.3.1}/main.py +0 -0
  111. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos/__init__.py +0 -0
  112. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos/base_plugin.py +0 -0
  113. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos/base_tool.py +0 -0
  114. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos/cli.py +0 -0
  115. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos/container.py +0 -0
  116. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos/context.py +0 -0
  117. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos/kernel.py +0 -0
  118. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos/project.py +0 -0
  119. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos/project_readme.md +0 -0
  120. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos/registry.py +0 -0
  121. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos/upgrade.py +0 -0
  122. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos_dev/__init__.py +0 -0
  123. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos_dev/cli.py +0 -0
  124. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos_dev/fuzzer.py +0 -0
  125. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos_dev/pipeline.py +0 -0
  126. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos_dev/plan/__init__.py +0 -0
  127. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos_dev/plan/rules.py +0 -0
  128. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos_dev/plan/scan.py +0 -0
  129. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos_dev/plan/schema.py +0 -0
  130. {microcoreos-0.2.2 → microcoreos-0.3.1}/microcoreos_dev/probe.py +0 -0
  131. {microcoreos-0.2.2 → microcoreos-0.3.1}/plans/README.md +0 -0
  132. {microcoreos-0.2.2 → microcoreos-0.3.1}/plans/active_plan.md +0 -0
  133. {microcoreos-0.2.2 → microcoreos-0.3.1}/plans/active_plan.yaml +0 -0
  134. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/conftest.py +0 -0
  135. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/dev/corpus/README.md +0 -0
  136. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/dev/corpus/qwen_twitter_plan.yaml +0 -0
  137. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/dev/test_pipeline.py +0 -0
  138. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/dev/test_plan_validator.py +0 -0
  139. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/domains/ping/test_ping_plugin.py +0 -0
  140. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/domains/users/test_create_user_plugin.py +0 -0
  141. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/domains/users/test_delete_user_plugin.py +0 -0
  142. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/domains/users/test_get_me_plugin.py +0 -0
  143. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/domains/users/test_get_user_by_id_plugin.py +0 -0
  144. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/domains/users/test_get_users_plugin.py +0 -0
  145. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/domains/users/test_login_plugin.py +0 -0
  146. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/domains/users/test_logout_plugin.py +0 -0
  147. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/domains/users/test_update_user_plugin.py +0 -0
  148. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/domains/users/test_welcome_service_plugin.py +0 -0
  149. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/helpers/active_db.py +0 -0
  150. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/helpers/async_wait.py +0 -0
  151. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/helpers/mock_db.py +0 -0
  152. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/helpers/trace_chains.py +0 -0
  153. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_chaos_control.py +0 -0
  154. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_chaos_tool.py +0 -0
  155. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_cli.py +0 -0
  156. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_config_tool.py +0 -0
  157. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_discovery_naming_linter.py +0 -0
  158. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_domain_isolation_linter.py +0 -0
  159. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_durable_one_shots.py +0 -0
  160. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_event_bus_groups.py +0 -0
  161. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_event_contract_linter.py +0 -0
  162. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_event_schemas_plugin.py +0 -0
  163. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_field_divergence_linter.py +0 -0
  164. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_http_params_hardening.py +0 -0
  165. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_logger_tool.py +0 -0
  166. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_no_retry.py +0 -0
  167. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_plugin_di_fixtures.py +0 -0
  168. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_postgresql_describe_schema.py +0 -0
  169. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_postgresql_tool.py +0 -0
  170. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_registry_collisions.py +0 -0
  171. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_route_collision_linter.py +0 -0
  172. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_s3_tool.py +0 -0
  173. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_scaffold.py +0 -0
  174. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_scheduler_singleton.py +0 -0
  175. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_security_hardening.py +0 -0
  176. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_sqlite_concurrency.py +0 -0
  177. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_sqlite_describe_schema.py +0 -0
  178. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_sqlite_migrations.py +0 -0
  179. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_state_tool.py +0 -0
  180. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_system_events_stats.py +0 -0
  181. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_system_traces_plugin.py +0 -0
  182. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_table_ownership_linter.py +0 -0
  183. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_tool_doc_drift_linter.py +0 -0
  184. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_trace_chain_helper.py +0 -0
  185. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/test_upgrade.py +0 -0
  186. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/tools/test_db_parity.py +0 -0
  187. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/tools/test_event_bus_kafka_parity.py +0 -0
  188. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/tools/test_event_bus_rabbitmq_parity.py +0 -0
  189. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/tools/test_redis_streams_driver.py +0 -0
  190. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/tools/test_s3_parity.py +0 -0
  191. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/tools/test_sqlite_driver.py +0 -0
  192. {microcoreos-0.2.2 → microcoreos-0.3.1}/tests/tools/test_state_parity.py +0 -0
  193. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/config/config_tool.py +0 -0
  194. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/context/authoring_guide.md +0 -0
  195. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/context/context_tool.py +0 -0
  196. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/context/renderers.py +0 -0
  197. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/context/scanners.py +0 -0
  198. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/event_bus/drivers.py +0 -0
  199. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/event_bus/envelope.py +0 -0
  200. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/event_bus/event_bus_tool.py +0 -0
  201. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/event_bus/redis_streams_driver.py +0 -0
  202. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/http_server/context.py +0 -0
  203. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/logger/logger_tool.py +0 -0
  204. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/sqlite/errors.py +0 -0
  205. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/sqlite/migrations.py +0 -0
  206. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/sqlite/transaction.py +0 -0
  207. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/state/state_tool.py +0 -0
  208. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/system/registry_tool.py +0 -0
  209. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/telemetry/__init__.py +0 -0
  210. {microcoreos-0.2.2 → microcoreos-0.3.1}/tools/telemetry/telemetry_tool.py +0 -0
@@ -0,0 +1,191 @@
1
+ # ╔════════════════════════════════════════════════════════════════════════════╗
2
+ # ║ MicroCoreOS — every environment variable, with its default. ║
3
+ # ║ ║
4
+ # ║ This file is the REFERENCE: commented options belong here. ║
5
+ # ║ Your .env is STATE: only settings in effect, none commented out. ║
6
+ # ║ ║
7
+ # ║ `microcoreos add <extra>` appends a section like these to your .env, so ║
8
+ # ║ leaving a variable commented there makes it get appended a second time. ║
9
+ # ╚════════════════════════════════════════════════════════════════════════════╝
10
+
11
+ # ╭────────────────────────────────────────────────────────────────────────────╮
12
+ # │ HTTP SERVER tools/http_server │
13
+ # ╰────────────────────────────────────────────────────────────────────────────╯
14
+
15
+ HTTP_PORT=5000
16
+
17
+ # Bind address. Default: 127.0.0.1 (dev-safe).
18
+ # PRODUCTION / DOCKER: must be 0.0.0.0 or the service refuses external connections.
19
+ # HTTP_HOST=0.0.0.0
20
+
21
+ # CORS allowed origins. Default: * (dev-friendly).
22
+ # Production: your actual frontend domain(s), comma-separated.
23
+ # HTTP_CORS_ORIGINS=https://app.example.com,https://admin.example.com
24
+
25
+ # Let cross-origin requests carry cookies. Default: false. Needed only when a
26
+ # browser app on ANOTHER origin authenticates by cookie instead of by Bearer
27
+ # token. Requires an explicit HTTP_CORS_ORIGINS list — the tool refuses to boot
28
+ # with '*', which would authorize every site to send the session cookie.
29
+ # HTTP_CORS_CREDENTIALS=true
30
+
31
+ # Uvicorn log level. Default: warning. Set to info for HTTP access logs.
32
+ # HTTP_LOG_LEVEL=info
33
+
34
+ # ╭────────────────────────────────────────────────────────────────────────────╮
35
+ # │ DATABASE — SQLite tools/sqlite │
36
+ # ╰────────────────────────────────────────────────────────────────────────────╯
37
+
38
+ SQLITE_DB_PATH=database.db
39
+
40
+ # Migrations policy. Default: true (boot applies pending migrations).
41
+ # PRODUCTION: migrating from a replica is PROHIBITED — set false in EVERY
42
+ # replica. Migrations run ONLY as a CI/CD step, in a SINGLE instance, under
43
+ # explicit human supervision:
44
+ # DB_AUTO_MIGRATE=true uv run main.py --boot-tool db
45
+ # DB_AUTO_MIGRATE=false
46
+
47
+ # ╭────────────────────────────────────────────────────────────────────────────╮
48
+ # │ EVENT BUS tools/event_bus │
49
+ # ╰────────────────────────────────────────────────────────────────────────────╯
50
+
51
+ # Driver. Default: in_process (same-process, zero infrastructure).
52
+ # Options: in_process | sqlite | redis_streams | kafka | rabbitmq
53
+ # The last two need `microcoreos add kafka` / `add rabbitmq` — see their sections.
54
+ # EVENT_BUS_DRIVER=in_process
55
+
56
+ # Dead-letter queue for events that exhausted their retries. Default: true.
57
+ # EVENT_BUS_DLQ_ENABLED=false
58
+
59
+ # --- sqlite driver: durable queue on disk, no broker ---
60
+ # EVENT_BUS_SQLITE_PATH=event_bus_queue.db
61
+ # EVENT_BUS_SQLITE_MAXLEN=10000 # rows kept per stream
62
+ # EVENT_BUS_SQLITE_POLL_MS=25 # consumer poll interval
63
+ # EVENT_BUS_SQLITE_SYNCHRONOUS=FULL # FULL | NORMAL — NORMAL is faster, less durable
64
+
65
+ # --- redis_streams driver: also needs the REDIS section below ---
66
+ # EVENT_BUS_STREAM_MAXLEN=10000 # entries kept per stream
67
+ # EVENT_BUS_CLAIM_IDLE_MS=60000 # ms before a stalled message is reclaimed
68
+ # EVENT_BUS_DELAY_POLL_MS=500 # delayed-delivery poll interval
69
+
70
+ # ╭────────────────────────────────────────────────────────────────────────────╮
71
+ # │ TELEMETRY — OpenTelemetry tools/telemetry │
72
+ # ╰────────────────────────────────────────────────────────────────────────────╯
73
+
74
+ # ALL tool calls get spans automatically via ToolProxy — no plugin changes.
75
+ # Install first:
76
+ # uv add opentelemetry-sdk opentelemetry-exporter-otlp opentelemetry-instrumentation-fastapi
77
+ OTEL_ENABLED=false
78
+ OTEL_SERVICE_NAME=microcoreos
79
+
80
+ # Export destination. Commented = console exporter (dev).
81
+ # Jaeger, Grafana Tempo, or any OTLP/gRPC backend.
82
+ # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
83
+
84
+ # BatchSpanProcessor tuning (OTel standard vars)
85
+ # OTEL_BSP_SCHEDULE_DELAY=5000 # ms between export batches
86
+ # OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512 # spans per batch
87
+ # OTEL_BSP_MAX_QUEUE_SIZE=2048 # in-memory buffer size
88
+
89
+ # ╭────────────────────────────────────────────────────────────────────────────╮
90
+ # │ HEALTH CHECK domains/system │
91
+ # ╰────────────────────────────────────────────────────────────────────────────╯
92
+
93
+ # ToolHealthPlugin calls db.health_check() periodically and updates the registry.
94
+ HEALTH_CHECK_INTERVAL=30
95
+
96
+ # ╭────────────────────────────────────────────────────────────────────────────╮
97
+ # │ METRICS always on │
98
+ # ╰────────────────────────────────────────────────────────────────────────────╯
99
+
100
+ # Timing needs no config — ToolProxy measures every tool call:
101
+ # records = self.registry.get_metrics()
102
+ # self.registry.add_metrics_sink(callback)
103
+ # Each record: {tool, method, duration_ms, success, timestamp}
104
+
105
+ # ╭────────────────────────────────────────────────────────────────────────────╮
106
+ # │ AUTHENTICATION microcoreos add auth │
107
+ # ╰────────────────────────────────────────────────────────────────────────────╯
108
+
109
+ # AUTH_SECRET_KEY=change-me # REQUIRED, 32 chars minimum
110
+ # AUTH_TOKEN_EXPIRE_MINUTES=60
111
+ # AUTH_ALGORITHM=HS256
112
+
113
+ # ╭────────────────────────────────────────────────────────────────────────────╮
114
+ # │ DATABASE — PostgreSQL microcoreos add postgres │
115
+ # ╰────────────────────────────────────────────────────────────────────────────╯
116
+
117
+ # Replaces the sqlite tool: the LAST one to register wins the 'db' key.
118
+ # PG_HOST=localhost
119
+ # PG_PORT=5432
120
+ # PG_USER=postgres
121
+ # PG_PASSWORD=postgres
122
+ # PG_DATABASE=microcoreos
123
+ # PG_MIN_POOL=1
124
+ # PG_MAX_POOL=10
125
+ # PG_CONNECT_TIMEOUT=5
126
+ # PG_COMMAND_TIMEOUT=30
127
+
128
+ # ╭────────────────────────────────────────────────────────────────────────────╮
129
+ # │ REDIS microcoreos add redis │
130
+ # ╰────────────────────────────────────────────────────────────────────────────╯
131
+
132
+ # Swaps the in-memory state tool. For the Redis Streams EVENT BUS instead,
133
+ # set EVENT_BUS_DRIVER=redis_streams — that driver already ships in tools/event_bus/.
134
+ # REDIS_HOST=localhost
135
+ # REDIS_PORT=6379
136
+ # REDIS_DB=0
137
+ # REDIS_PASSWORD= # empty for no auth
138
+ # REDIS_CONNECT_TIMEOUT=5
139
+
140
+ # ╭────────────────────────────────────────────────────────────────────────────╮
141
+ # │ S3 STORAGE microcoreos add s3 │
142
+ # ╰────────────────────────────────────────────────────────────────────────────╯
143
+
144
+ # AWS_ACCESS_KEY_ID=your-access-key
145
+ # AWS_SECRET_ACCESS_KEY=your-secret-key
146
+ # AWS_DEFAULT_REGION=us-east-1
147
+ # AWS_S3_ENDPOINT_URL=http://localhost:9000 # MinIO in dev; drop for real S3
148
+ # AWS_S3_DEFAULT_BUCKET=microcoreos-bucket
149
+ # AWS_S3_SIZE_LIMIT_ENABLED=true
150
+ # AWS_S3_MAX_FILE_SIZE_MB=10
151
+ # AWS_S3_VERIFY_SSL=true
152
+
153
+ # ╭────────────────────────────────────────────────────────────────────────────╮
154
+ # │ SCHEDULER microcoreos add scheduler │
155
+ # ╰────────────────────────────────────────────────────────────────────────────╯
156
+
157
+ # false on worker replicas: jobs register everywhere, fire in ONE beat replica.
158
+ # SCHEDULER_ENABLED=true
159
+
160
+ # ╭────────────────────────────────────────────────────────────────────────────╮
161
+ # │ KAFKA microcoreos add kafka │
162
+ # ╰────────────────────────────────────────────────────────────────────────────╯
163
+
164
+ # EVENT_BUS_DRIVER=kafka
165
+ # KAFKA_BOOTSTRAP_SERVERS=localhost:9092
166
+ # KAFKA_BUS_TOPIC_PREFIX=bus.
167
+ # KAFKA_BUS_PARTITIONS=6
168
+ # KAFKA_BUS_REPLICATION=1
169
+ # KAFKA_CONNECT_TIMEOUT=5
170
+ # KAFKA_MAX_POLL_INTERVAL_MS=300000
171
+
172
+ # ╭────────────────────────────────────────────────────────────────────────────╮
173
+ # │ RABBITMQ microcoreos add rabbitmq │
174
+ # ╰────────────────────────────────────────────────────────────────────────────╯
175
+
176
+ # EVENT_BUS_DRIVER=rabbitmq
177
+ # RABBITMQ_HOST=localhost
178
+ # RABBITMQ_PORT=5672
179
+ # RABBITMQ_USER=guest
180
+ # RABBITMQ_PASSWORD=guest
181
+ # RABBITMQ_VHOST=/
182
+ # RABBITMQ_PREFETCH=16
183
+ # RABBITMQ_CONNECT_TIMEOUT=5
184
+
185
+ # ╭────────────────────────────────────────────────────────────────────────────╮
186
+ # │ CHAOS ENGINEERING microcoreos add chaos │
187
+ # ╰────────────────────────────────────────────────────────────────────────────╯
188
+
189
+ # Intentionally fails tools/plugins on boot to test fault tolerance.
190
+ # NEVER enable in production.
191
+ # CHAOS_ENABLED=true
@@ -55,3 +55,10 @@ package-lock.json
55
55
  # as a submodule with no .gitmodules, and every checkout since failed its
56
56
  # cleanup step with exit 128.
57
57
  .claude/worktrees/
58
+
59
+ # Test Coverage & Mutation Testing
60
+ .coverage
61
+ htmlcov/
62
+ .mutmut-cache/
63
+ mutants/
64
+
@@ -23,7 +23,8 @@ HTTP Server Tool (http):
23
23
  'data' = flat merge of [path params] + [query params] + [body/form fields].
24
24
  Special keys in 'data':
25
25
  - data["_auth"]: contains the payload from auth_validator if successful.
26
- - data["_files"]: list of FastAPI UploadFile objects (only if has_files=True).
26
+ - data["_files"]: list of UploadedFile objects (only if has_files=True).
27
+ Fields: .filename, .content_type, .stream (sync file object), await .read().
27
28
  - SECURITY DEFAULTS:
28
29
  - Cookies set via context.set_cookie are 'Secure=True', 'HttpOnly=True', 'SameSite=Lax'.
29
30
  - CSRF Guard: Mutations (POST/PUT/DELETE) using cookie auth REQUIRE 'X-Requested-With' header.
@@ -35,8 +36,19 @@ HTTP Server Tool (http):
35
36
  - has_files: if True, enables multipart/form-data. Request model fields
36
37
  become Form fields. To use a file: file = data["_files"][0];
37
38
  await s3.upload_fileobj(file.filename, file.file, content_type=file.content_type)
38
- - mount_static(path, directory_path): Serve static files from a directory.
39
- - add_ws_endpoint(path, on_connect, on_disconnect=None): WebSocket support.
39
+ - mount_static(path, directory_path, html=False, allow_extensions=None):
40
+ Serve static files from a directory. Deny by default: only files whose
41
+ extension is allowed are served (default DEFAULT_STATIC_EXTENSIONS; pass
42
+ a set to declare your own, or "*" to serve everything). Dotfiles are
43
+ always refused except under '.well-known/'. Use html=True to serve
44
+ index.html for directory requests, which a UI/SPA mounted at "/" needs.
45
+ Raises ValueError if the directory does not exist.
46
+ - add_ws_endpoint(path, on_connect, on_disconnect=None, auth_validator=None):
47
+ WebSocket support. on_connect receives a WebSocketConnection: send_text,
48
+ send_json, receive_text, receive_json, close, query_params, path_params.
49
+ With auth_validator the token is read from the Authorization header, the
50
+ `token` query param, then the access_token cookie; an invalid one is
51
+ closed with 1008 BEFORE the handshake and on_connect takes (conn, payload).
40
52
  - add_sse_endpoint(path, generator, tags=None, auth_validator=None):
41
53
  Server-Sent Events. generator yields formatted strings: "data: {...}\n\n".
42
54
  - register_pre_mount_hook(hook): hook(endpoints: list[dict]) is called once in
@@ -286,6 +298,33 @@ Async SQLite Persistence Tool (sqlite):
286
298
 
287
299
  ## 📦 Domains
288
300
 
301
+ ### `chaos`
302
+ - **Tables**: none
303
+ - **Endpoints**:
304
+ - `GET /system/chaos`
305
+ - **res**: ChaosStateData(faults: list[FaultSpecView(tool: str, source: str, mode: str, plugin: Optional[str], rate: Optional[float], seconds: Optional[float])], wrapped_tools: list[str], paused_plugins: list[str])
306
+ - `POST /system/chaos/fail`
307
+ - **req**: plugin: str, tool: Optional[str], rate: float
308
+ - **res**: FailData(plugin: str, tool: Optional[str], rate: float)
309
+ - `POST /system/chaos/latency`
310
+ - **req**: plugin: Optional[str], tool: Optional[str], seconds: float
311
+ - **res**: LatencyData(plugin: Optional[str], tool: Optional[str], seconds: float)
312
+ - `POST /system/chaos/off`
313
+ - **req**: plugin: str
314
+ - **res**: PluginPauseData(plugin: str, paused: bool)
315
+ - `POST /system/chaos/on`
316
+ - **req**: plugin: str
317
+ - **res**: PluginPauseData(plugin: str, paused: bool)
318
+ - `POST /system/chaos/reset`
319
+ - **res**: ResetData(cleared: int, restored_tools: list[str], resumed_plugins: list[str])
320
+ - `POST /system/chaos/tool`
321
+ - **req**: name: str, mode: Literal['down', 'slow', 'flaky', 'off'], seconds: float, rate: float
322
+ - **res**: ToolFaultData(name: str, mode: str)
323
+ - **Events emitted**: `system.chaos.fail_armed` (plugin, rate, tool), `system.chaos.latency_armed` (plugin, seconds, tool), `system.chaos.plugin_paused` (plugin), `system.chaos.plugin_resumed` (plugin), `system.chaos.reset` (cleared), `system.chaos.tool_fault_armed` (mode, tool)
324
+ - **Events consumed**: none
325
+ - **Dependencies**: container, event_bus, http, logger
326
+ - **Plugins**: chaos.ChaosControlPlugin
327
+
289
328
  ### `devtools`
290
329
  - **Tables**: none
291
330
  - **Endpoints**:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microcoreos
3
- Version: 0.2.2
3
+ Version: 0.3.1
4
4
  Summary: Atomic Microkernel Architecture optimized for AI-Driven Development
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -9,6 +9,7 @@ Requires-Dist: aiosqlite>=0.20.0
9
9
  Requires-Dist: fastapi>=0.115.0
10
10
  Requires-Dist: pydantic[email]>=2.10.0
11
11
  Requires-Dist: python-dotenv>=1.0.0
12
+ Requires-Dist: python-multipart>=0.0.9
12
13
  Requires-Dist: pyyaml>=6.0.3
13
14
  Requires-Dist: uvicorn>=0.30.0
14
15
  Requires-Dist: websockets>=13.0
@@ -28,6 +28,8 @@ REPLACEMENT STANDARD (plugins unaffected):
28
28
  from decode_token. validate_token returns None instead of raising.
29
29
  """
30
30
 
31
+ import base64
32
+ import hashlib
31
33
  import os
32
34
  import asyncio
33
35
  import bcrypt
@@ -37,6 +39,20 @@ from typing import Optional
37
39
  from microcoreos import BaseTool
38
40
 
39
41
 
42
+ def _prehash(password: str) -> bytes:
43
+ """
44
+ bcrypt hashes at most 72 bytes and stops at the first NUL, both silently:
45
+ two passwords sharing a 72-byte prefix verify against the same hash.
46
+ Hashing first makes every input a fixed 44-byte, NUL-free digest, so the
47
+ whole password decides the result. base64 rather than hex to stay well
48
+ under the limit. This is passlib's bcrypt_sha256 construction.
49
+
50
+ Changing this function invalidates every stored hash — they can only be
51
+ reissued by a password reset, since the plaintext is not recoverable.
52
+ """
53
+ return base64.b64encode(hashlib.sha256(password.encode("utf-8")).digest())
54
+
55
+
40
56
  class AuthError(Exception):
41
57
  """Base class for authentication failures."""
42
58
 
@@ -74,9 +90,12 @@ class AuthTool(BaseTool):
74
90
  - PURPOSE: Manage system security, password hashing, and JWT token lifecycle.
75
91
  - CAPABILITIES:
76
92
  - await hash_password(password: str) -> str: Securely hashes a plain-text
77
- password using bcrypt. Async — runs in a thread (bcrypt is CPU-bound).
93
+ password: SHA-256 first, then bcrypt, so the whole password counts
94
+ regardless of length (bare bcrypt silently ignores everything past
95
+ 72 bytes). Async — runs in a thread (bcrypt is CPU-bound).
78
96
  - await verify_password(password: str, hashed_password: str) -> bool:
79
97
  Verifies if a password matches its hash. Async — runs in a thread.
98
+ Only hashes produced by this tool's hash_password() verify.
80
99
  - create_token(data: dict, expires_delta: Optional[int] = None) -> str:
81
100
  Generates a JWT signed token. 'data' should contain claims (e.g. {'sub': user_id}).
82
101
  'expires_delta' is optional minutes until expiration.
@@ -92,12 +111,12 @@ class AuthTool(BaseTool):
92
111
  # bcrypt is CPU-bound (~100ms by design) — run in a thread so it
93
112
  # never blocks the event loop under concurrent requests.
94
113
  return await asyncio.to_thread(
95
- lambda: bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
114
+ lambda: bcrypt.hashpw(_prehash(password), bcrypt.gensalt()).decode()
96
115
  )
97
116
 
98
117
  async def verify_password(self, password: str, hashed_password: str) -> bool:
99
118
  return await asyncio.to_thread(
100
- bcrypt.checkpw, password.encode(), hashed_password.encode()
119
+ bcrypt.checkpw, _prehash(password), hashed_password.encode()
101
120
  )
102
121
 
103
122
  def create_token(self, data: dict, expires_delta: Optional[int] = None) -> str:
@@ -12,6 +12,10 @@ PUBLIC CONTRACT (what plugins use):
12
12
  job_id = scheduler.add_job("0 * * * *", self.on_every_hour)
13
13
  job_id = scheduler.add_job("*/5 * * * *", self.send_digest, job_id="digest")
14
14
 
15
+ # Recurring job — fixed interval, for the sub-minute rates cron cannot express
16
+ job_id = scheduler.add_interval_job(1.0, self.sample_metrics)
17
+ job_id = scheduler.add_interval_job(0.25, self.poll, job_id="poll", max_instances=4)
18
+
15
19
  # One-shot job — runs once at a specific datetime
16
20
  from datetime import datetime, timedelta, timezone
17
21
  run_at = datetime.now(timezone.utc) + timedelta(minutes=30)
@@ -86,13 +90,23 @@ REPLACEMENT STANDARD (swap without changing plugins):
86
90
  To replace with Celery beat or any other scheduler:
87
91
  1. Create tools/{name}/{name}_tool.py
88
92
  2. Set name = "scheduler" ← same injection key
89
- 3. Implement the 4 public methods:
93
+ 3. Implement the 5 public methods:
90
94
  add_job(cron_expr, callback, job_id?) → str
95
+ add_interval_job(seconds, callback, job_id?, ...) → str
91
96
  add_one_shot(run_at, callback, job_id?) → str
92
97
  remove_job(job_id) → bool
93
98
  list_jobs() → list[dict]
94
99
  4. Honor SCHEDULER_ENABLED (jobs register everywhere, fire in one place).
95
100
  Plugins do not change.
101
+
102
+ LIMIT OF THIS CONTRACT: every method above takes a Python callable, so it
103
+ is implementable only by a scheduler running IN THIS PROCESS (APScheduler,
104
+ `schedule`, a bare asyncio loop). Celery beat and every other distributed
105
+ beat dispatch task NAMES to a broker for other processes to run, and a
106
+ bound method does not cross a process boundary. Distributed scheduling is
107
+ reached the other way — by scheduling an EVENT rather than a function:
108
+ "scheduler.one_shot.schedule" on the bus, where everything crossing the
109
+ boundary is JSON. See durable_one_shots_plugin.py.
96
110
  """
97
111
 
98
112
  import functools
@@ -170,7 +184,11 @@ class SchedulerTool(BaseTool):
170
184
  def setup(self) -> None:
171
185
  try:
172
186
  from apscheduler.schedulers.asyncio import AsyncIOScheduler
187
+ from apscheduler.events import EVENT_JOB_MISSED, EVENT_JOB_MAX_INSTANCES
173
188
  self._scheduler = AsyncIOScheduler()
189
+ self._scheduler.add_listener(
190
+ self._on_run_dropped, EVENT_JOB_MISSED | EVENT_JOB_MAX_INSTANCES
191
+ )
174
192
  print("[Scheduler] APScheduler initialized.")
175
193
  except ImportError:
176
194
  raise RuntimeError(
@@ -188,6 +206,23 @@ class SchedulerTool(BaseTool):
188
206
  self._scheduler.start()
189
207
  print(f"[Scheduler] Started — {job_count} job(s) registered.")
190
208
 
209
+ def _on_run_dropped(self, event) -> None:
210
+ """
211
+ APScheduler discards runs without raising: one past its misfire grace
212
+ time (EVENT_JOB_MISSED) or one that would exceed max_instances
213
+ (EVENT_JOB_MAX_INSTANCES) never reaches the callback and returns no
214
+ error to anybody. Unlogged, a job firing less often than it was
215
+ configured to is indistinguishable from one firing correctly.
216
+ """
217
+ from apscheduler.events import EVENT_JOB_MAX_INSTANCES
218
+
219
+ reason = ("overlapped a still-running execution (max_instances)"
220
+ if event.code == EVENT_JOB_MAX_INSTANCES
221
+ else "was later than its misfire_grace_time")
222
+ # JobExecutionEvent carries scheduled_run_time; JobSubmissionEvent, a list.
223
+ when = getattr(event, "scheduled_run_time", None) or getattr(event, "scheduled_run_times", None)
224
+ print(f"[Scheduler] Run DROPPED — id={event.job_id!r} {reason}, scheduled for {when}")
225
+
191
226
  def shutdown(self) -> None:
192
227
  if self._scheduler and self._scheduler.running:
193
228
  self._scheduler.shutdown(wait=False)
@@ -229,6 +264,75 @@ class SchedulerTool(BaseTool):
229
264
  print(f"[Scheduler] Job registered — id={job_id!r} cron={cron_expr!r}")
230
265
  return job_id
231
266
 
267
+ def add_interval_job(
268
+ self,
269
+ seconds: float,
270
+ callback: Callable,
271
+ job_id: Optional[str] = None,
272
+ *,
273
+ minutes: float = 0,
274
+ hours: float = 0,
275
+ max_instances: int = 1,
276
+ coalesce: bool = True,
277
+ misfire_grace_time: Optional[int] = 1,
278
+ ) -> str:
279
+ """
280
+ Schedule a recurring job on a fixed interval.
281
+
282
+ Cron's smallest unit is the minute, so anything faster — and any rate
283
+ that is not a whole number of minutes — has to be an interval.
284
+
285
+ Parameters:
286
+ seconds: Interval in seconds; accepts fractions (0.25 = 4x/second).
287
+ Combined additively with minutes and hours.
288
+ callback: Sync or async callable. Called with no arguments.
289
+ job_id: Optional stable ID. Auto-generated if omitted.
290
+
291
+ The last three mirror APScheduler's job defaults and matter as the
292
+ interval approaches the callback's own duration:
293
+
294
+ max_instances: concurrent runs allowed. At 1, a run starting
295
+ while the previous one is still going is
296
+ DROPPED, not queued.
297
+ coalesce: collapse several missed runs into one.
298
+ misfire_grace_time: seconds late a run may start; past that it is
299
+ DROPPED. None means run it however late.
300
+
301
+ Dropped runs raise nothing — they are reported by _on_run_dropped().
302
+ A job that must not skip needs max_instances above 1, a callback
303
+ faster than the interval, or both.
304
+
305
+ Returns: the job_id string.
306
+
307
+ Examples:
308
+ scheduler.add_interval_job(1.0, self.sample_metrics)
309
+ scheduler.add_interval_job(0.25, self.poll, job_id="poll", max_instances=4)
310
+ scheduler.add_interval_job(0, self.hourly, minutes=90)
311
+ """
312
+ from apscheduler.triggers.interval import IntervalTrigger
313
+
314
+ if seconds <= 0 and minutes <= 0 and hours <= 0:
315
+ raise ValueError(
316
+ "add_interval_job: interval must be positive — "
317
+ f"got seconds={seconds}, minutes={minutes}, hours={hours}"
318
+ )
319
+
320
+ job_id = job_id or uuid.uuid4().hex
321
+ self._scheduler.add_job(
322
+ _with_identity(callback),
323
+ trigger=IntervalTrigger(seconds=seconds, minutes=minutes, hours=hours),
324
+ id=job_id,
325
+ replace_existing=True,
326
+ max_instances=max_instances,
327
+ coalesce=coalesce,
328
+ misfire_grace_time=misfire_grace_time,
329
+ )
330
+ print(
331
+ f"[Scheduler] Interval job registered — id={job_id!r} "
332
+ f"every {seconds}s+{minutes}m+{hours}h max_instances={max_instances}"
333
+ )
334
+ return job_id
335
+
232
336
  def add_one_shot(
233
337
  self,
234
338
  run_at: datetime,
@@ -307,6 +411,15 @@ class SchedulerTool(BaseTool):
307
411
  e.g. "*/5 * * * *" = every 5 min, "0 9 * * 1-5" = weekdays at 09:00.
308
412
  Returns job_id (auto-generated if not provided).
309
413
  Providing a stable job_id prevents duplicates on restart.
414
+ - add_interval_job(seconds: float, callback, job_id?: str, *, minutes, hours,
415
+ max_instances=1, coalesce=True, misfire_grace_time=1) -> str:
416
+ Schedule a recurring job on a fixed interval. Use this for sub-minute
417
+ rates, which a 5-field cron expression cannot express (its unit is the
418
+ minute). seconds accepts fractions: 0.25 = 4x/second.
419
+ At max_instances=1 a run that overlaps the previous one is DROPPED, and
420
+ a run later than misfire_grace_time is DROPPED — silently, as far as the
421
+ callback is concerned. Both are logged as "Run DROPPED". Raise
422
+ max_instances if the job must not skip.
310
423
  - add_one_shot(run_at: datetime, callback, job_id?: str) -> str:
311
424
  Schedule a one-time job at a specific datetime (timezone-aware).
312
425
  Returns job_id. IN-MEMORY: lost if the process restarts before firing.
@@ -174,12 +174,48 @@ def _install_dependency(extra: str, root: str) -> bool:
174
174
  return True
175
175
 
176
176
 
177
+ ENV_BOX_WIDTH = 76
178
+
179
+
180
+ def _env_section_header(title: str, source: str) -> list:
181
+ """
182
+ One boxed heading per tool, so .env stays readable as extras accumulate.
183
+ .env.example uses this same function; keep the three lines equal in width
184
+ or the boxes stop lining up.
185
+ """
186
+ left = f" {title}"
187
+ pad = ENV_BOX_WIDTH - len(left) - len(source) - 2
188
+ return [
189
+ f"# ╭{'─' * ENV_BOX_WIDTH}╮",
190
+ f"# │{left}{' ' * max(pad, 1)}{source} │",
191
+ f"# ╰{'─' * ENV_BOX_WIDTH}╯",
192
+ ]
193
+
194
+
195
+ def _env_state(existing: str, var: str) -> str:
196
+ """Whether var is absent, set, or present but commented out."""
197
+ for line in existing.splitlines():
198
+ stripped = line.strip()
199
+ if stripped.startswith(f"{var}="):
200
+ return "set"
201
+ if stripped.startswith("#") and stripped.lstrip("#").strip().startswith(f"{var}="):
202
+ return "commented"
203
+ return "absent"
204
+
205
+
177
206
  def _append_env(root: str, name: str, entries) -> None:
178
207
  """
179
208
  Append the extra's settings to .env, once.
180
209
 
181
210
  Anything already defined there is the user's decision and is left alone —
182
211
  re-running `add` must never rewrite a password someone typed.
212
+
213
+ A commented-out setting counts as already there. It is not appended again:
214
+ python-dotenv gives precedence to the LAST occurrence, so the duplicate
215
+ would override the line above it and editing that line would do nothing.
216
+ Which of the two the user meant is not knowable here — commenting a
217
+ variable reads equally as "I want the default" and as "I will fill this in
218
+ later" — so the choice is reported rather than made.
183
219
  """
184
220
  if not entries:
185
221
  return
@@ -189,12 +225,18 @@ def _append_env(root: str, name: str, entries) -> None:
189
225
  if os.path.exists(path):
190
226
  existing = open(path, encoding="utf-8").read()
191
227
 
192
- missing = [e for e in entries if f"\n{e[0]}=" not in f"\n{existing}"]
228
+ states = {e[0]: _env_state(existing, e[0]) for e in entries}
229
+ missing = [e for e in entries if states[e[0]] == "absent"]
230
+ commented = [v for v, s in states.items() if s == "commented"]
231
+
232
+ for var in commented:
233
+ print(f" ! .env has {var} commented out — uncomment it or delete the line.")
234
+
193
235
  if not missing:
194
236
  print(" ✓ .env already has these settings — unchanged.")
195
237
  return
196
238
 
197
- block = [f"\n# ─── {name} (added by `microcoreos add {name}`) ───"]
239
+ block = [""] + _env_section_header(name.upper(), f"microcoreos add {name}") + [""]
198
240
  for var, value, comment in missing:
199
241
  block.append(f"{var}={value}" + (f" # {comment}" if comment else ""))
200
242
 
@@ -376,6 +376,62 @@ def _install_test_deps(root: str) -> bool:
376
376
  return True
377
377
 
378
378
 
379
+ def _write_initial_env(example_path: str, env_path: str) -> None:
380
+ """
381
+ Initializes .env from .env.example.
382
+
383
+ `.env.example` is the reference containing all commented options for extras.
384
+ `.env` is active state: only settings in effect, omitting commented-out
385
+ optional extra settings so that `microcoreos add <extra>` can append them cleanly.
386
+ """
387
+ from microcoreos.catalog import CATALOG
388
+
389
+ extra_vars = {
390
+ var for extra in CATALOG.values() if extra.env for var, _, _ in extra.env
391
+ }
392
+
393
+ with open(example_path, encoding="utf-8") as f:
394
+ lines = f.readlines()
395
+
396
+ out_lines = []
397
+ skip_section = False
398
+
399
+ for line in lines:
400
+ stripped = line.strip()
401
+
402
+ # Omit commented-out settings for optional extras
403
+ if stripped.startswith("#") and any(
404
+ stripped.lstrip("#").strip().startswith(f"{var}=") for var in extra_vars
405
+ ):
406
+ continue
407
+
408
+ # Omit section header boxes for optional extras
409
+ if stripped.startswith("# │") and "microcoreos add" in stripped:
410
+ skip_section = True
411
+ if out_lines and "# ╭──" in out_lines[-1]:
412
+ out_lines.pop()
413
+ continue
414
+
415
+ if skip_section and ("# ╰──" in stripped or "# │" in stripped):
416
+ if "# ╰──" in stripped:
417
+ skip_section = False
418
+ continue
419
+
420
+ out_lines.append(line)
421
+
422
+ cleaned = []
423
+ prev_blank = False
424
+ for line_item in out_lines:
425
+ is_blank = not line_item.strip()
426
+ if is_blank and prev_blank:
427
+ continue
428
+ cleaned.append(line_item)
429
+ prev_blank = is_blank
430
+
431
+ with open(env_path, "w", encoding="utf-8") as f:
432
+ f.writelines(cleaned)
433
+
434
+
379
435
  def new(argv: list[str]) -> int:
380
436
  """`microcoreos new <path> [--force] [--no-ai-kit] [--no-install]`"""
381
437
  force = "--force" in argv
@@ -405,7 +461,7 @@ def new(argv: list[str]) -> int:
405
461
  # .env is configuration, not source: never clobber one that exists.
406
462
  env, example = os.path.join(target, ".env"), os.path.join(target, ".env.example")
407
463
  if os.path.exists(example) and not os.path.exists(env):
408
- shutil.copy2(example, env)
464
+ _write_initial_env(example, env)
409
465
 
410
466
  name = os.path.basename(target).replace("_", "-").lower() or "my-app"
411
467