enya-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (389) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/ci.yml +70 -0
  3. package/.github/workflows/publish.yml +250 -0
  4. package/.gitmodules +3 -0
  5. package/Cargo.lock +3584 -0
  6. package/Cargo.toml +97 -0
  7. package/crates/enact/Cargo.toml +27 -0
  8. package/crates/enact/src/lib.rs +60 -0
  9. package/crates/enact-a2a/Cargo.toml +25 -0
  10. package/crates/enact-a2a/src/lib.rs +411 -0
  11. package/crates/enact-channels/Cargo.toml +64 -0
  12. package/crates/enact-channels/examples/README.md +80 -0
  13. package/crates/enact-channels/examples/channel_bot.rs +169 -0
  14. package/crates/enact-channels/examples/telegram-echo.rs +34 -0
  15. package/crates/enact-channels/examples/whatsapp-echo.rs +142 -0
  16. package/crates/enact-channels/src/config.rs +213 -0
  17. package/crates/enact-channels/src/lib.rs +25 -0
  18. package/crates/enact-channels/src/runtime.rs +237 -0
  19. package/crates/enact-channels/src/security/mod.rs +5 -0
  20. package/crates/enact-channels/src/security/pairing.rs +205 -0
  21. package/crates/enact-channels/src/teams.rs +601 -0
  22. package/crates/enact-channels/src/telegram.rs +2833 -0
  23. package/crates/enact-channels/src/traits.rs +200 -0
  24. package/crates/enact-channels/src/webhook.rs +262 -0
  25. package/crates/enact-channels/src/whatsapp.rs +310 -0
  26. package/crates/enact-cli/Cargo.toml +40 -0
  27. package/crates/enact-cli/src/commands/doctor.rs +62 -0
  28. package/crates/enact-cli/src/commands/mod.rs +3 -0
  29. package/crates/enact-cli/src/commands/run.rs +69 -0
  30. package/crates/enact-cli/src/commands/serve.rs +81 -0
  31. package/crates/enact-cli/src/config.rs +2 -0
  32. package/crates/enact-cli/src/main.rs +79 -0
  33. package/crates/enact-config/Cargo.toml +36 -0
  34. package/crates/enact-config/ENV_VAR_MAPPING.md +135 -0
  35. package/crates/enact-config/QUICK_REFERENCE.md +92 -0
  36. package/crates/enact-config/README.md +107 -0
  37. package/crates/enact-config/TESTING.md +161 -0
  38. package/crates/enact-config/examples/test-env-vars.rs +100 -0
  39. package/crates/enact-config/src/config.rs +399 -0
  40. package/crates/enact-config/src/encrypted_store.rs +211 -0
  41. package/crates/enact-config/src/lib.rs +298 -0
  42. package/crates/enact-config/src/secrets.rs +149 -0
  43. package/crates/enact-config/src/sync.rs +260 -0
  44. package/crates/enact-config/test-env-vars.sh +34 -0
  45. package/crates/enact-config/tests/README.md +99 -0
  46. package/crates/enact-config/tests/config_integration_test.rs +202 -0
  47. package/crates/enact-config/tests/security_test.rs +140 -0
  48. package/crates/enact-context/Cargo.toml +41 -0
  49. package/crates/enact-context/src/budget.rs +314 -0
  50. package/crates/enact-context/src/calibrator.rs +535 -0
  51. package/crates/enact-context/src/compactor.rs +392 -0
  52. package/crates/enact-context/src/condenser.rs +826 -0
  53. package/crates/enact-context/src/lib.rs +94 -0
  54. package/crates/enact-context/src/segment.rs +238 -0
  55. package/crates/enact-context/src/step_context.rs +645 -0
  56. package/crates/enact-context/src/token_counter.rs +148 -0
  57. package/crates/enact-context/src/window.rs +372 -0
  58. package/crates/enact-core/Cargo.toml +42 -0
  59. package/crates/enact-core/README.md +98 -0
  60. package/crates/enact-core/src/background/executor.rs +524 -0
  61. package/crates/enact-core/src/background/mod.rs +48 -0
  62. package/crates/enact-core/src/background/target_binding.rs +390 -0
  63. package/crates/enact-core/src/background/trigger.rs +511 -0
  64. package/crates/enact-core/src/callable/callable.rs +152 -0
  65. package/crates/enact-core/src/callable/composite.rs +817 -0
  66. package/crates/enact-core/src/callable/graph.rs +104 -0
  67. package/crates/enact-core/src/callable/llm.rs +211 -0
  68. package/crates/enact-core/src/callable/mod.rs +64 -0
  69. package/crates/enact-core/src/callable/registry.rs +206 -0
  70. package/crates/enact-core/src/context/execution_context.rs +757 -0
  71. package/crates/enact-core/src/context/invocation.rs +99 -0
  72. package/crates/enact-core/src/context/mod.rs +50 -0
  73. package/crates/enact-core/src/context/tenant.rs +175 -0
  74. package/crates/enact-core/src/context/trace.rs +127 -0
  75. package/crates/enact-core/src/flow/conditional.rs +293 -0
  76. package/crates/enact-core/src/flow/mod.rs +43 -0
  77. package/crates/enact-core/src/flow/parallel.rs +437 -0
  78. package/crates/enact-core/src/flow/repeat.rs +534 -0
  79. package/crates/enact-core/src/flow/sequential.rs +248 -0
  80. package/crates/enact-core/src/graph/checkpoint.rs +79 -0
  81. package/crates/enact-core/src/graph/checkpoint_store.rs +76 -0
  82. package/crates/enact-core/src/graph/compiled.rs +189 -0
  83. package/crates/enact-core/src/graph/edge.rs +59 -0
  84. package/crates/enact-core/src/graph/graph_schema.rs +218 -0
  85. package/crates/enact-core/src/graph/loader.rs +155 -0
  86. package/crates/enact-core/src/graph/mod.rs +18 -0
  87. package/crates/enact-core/src/graph/node/function.rs +49 -0
  88. package/crates/enact-core/src/graph/node/mod.rs +48 -0
  89. package/crates/enact-core/src/graph/schema.rs +62 -0
  90. package/crates/enact-core/src/inbox/message.rs +405 -0
  91. package/crates/enact-core/src/inbox/mod.rs +31 -0
  92. package/crates/enact-core/src/inbox/store.rs +355 -0
  93. package/crates/enact-core/src/kernel/artifact/filesystem.rs +546 -0
  94. package/crates/enact-core/src/kernel/artifact/metadata.rs +283 -0
  95. package/crates/enact-core/src/kernel/artifact/mod.rs +27 -0
  96. package/crates/enact-core/src/kernel/artifact/store.rs +427 -0
  97. package/crates/enact-core/src/kernel/enforcement.rs +1315 -0
  98. package/crates/enact-core/src/kernel/error.rs +1200 -0
  99. package/crates/enact-core/src/kernel/event.rs +1394 -0
  100. package/crates/enact-core/src/kernel/execution_model.rs +831 -0
  101. package/crates/enact-core/src/kernel/execution_state.rs +189 -0
  102. package/crates/enact-core/src/kernel/execution_strategy.rs +117 -0
  103. package/crates/enact-core/src/kernel/ids.rs +2086 -0
  104. package/crates/enact-core/src/kernel/interrupt.rs +125 -0
  105. package/crates/enact-core/src/kernel/kernel.rs +1283 -0
  106. package/crates/enact-core/src/kernel/mod.rs +205 -0
  107. package/crates/enact-core/src/kernel/persistence/event_store.rs +270 -0
  108. package/crates/enact-core/src/kernel/persistence/message_store.rs +908 -0
  109. package/crates/enact-core/src/kernel/persistence/mod.rs +102 -0
  110. package/crates/enact-core/src/kernel/persistence/state_store.rs +228 -0
  111. package/crates/enact-core/src/kernel/persistence/vector_store.rs +299 -0
  112. package/crates/enact-core/src/kernel/reducer.rs +808 -0
  113. package/crates/enact-core/src/kernel/replay.rs +153 -0
  114. package/crates/enact-core/src/lib.rs +413 -0
  115. package/crates/enact-core/src/memory/episodic.rs +0 -0
  116. package/crates/enact-core/src/memory/mod.rs +6 -0
  117. package/crates/enact-core/src/memory/semantic.rs +0 -0
  118. package/crates/enact-core/src/memory/trait.rs +0 -0
  119. package/crates/enact-core/src/memory/vector_db.rs +0 -0
  120. package/crates/enact-core/src/memory/working.rs +0 -0
  121. package/crates/enact-core/src/policy/execution_policy.rs +292 -0
  122. package/crates/enact-core/src/policy/filters.rs +458 -0
  123. package/crates/enact-core/src/policy/input_processor.rs +407 -0
  124. package/crates/enact-core/src/policy/long_running.rs +134 -0
  125. package/crates/enact-core/src/policy/mod.rs +193 -0
  126. package/crates/enact-core/src/policy/pii_input.rs +274 -0
  127. package/crates/enact-core/src/policy/tenant_policy.rs +453 -0
  128. package/crates/enact-core/src/policy/tool_policy.rs +407 -0
  129. package/crates/enact-core/src/providers/mod.rs +63 -0
  130. package/crates/enact-core/src/providers/trait.rs +292 -0
  131. package/crates/enact-core/src/runner/callbacks.rs +6 -0
  132. package/crates/enact-core/src/runner/execution_runner.rs +476 -0
  133. package/crates/enact-core/src/runner/loop.rs +117 -0
  134. package/crates/enact-core/src/runner/mod.rs +58 -0
  135. package/crates/enact-core/src/runner/protected_runner.rs +280 -0
  136. package/crates/enact-core/src/signal/inmemory.rs +231 -0
  137. package/crates/enact-core/src/signal/mod.rs +108 -0
  138. package/crates/enact-core/src/streaming/event_logger.rs +195 -0
  139. package/crates/enact-core/src/streaming/event_stream.rs +1423 -0
  140. package/crates/enact-core/src/streaming/mod.rs +108 -0
  141. package/crates/enact-core/src/streaming/pause_cancel.rs +0 -0
  142. package/crates/enact-core/src/streaming/protected_emitter.rs +173 -0
  143. package/crates/enact-core/src/streaming/protection/context.rs +136 -0
  144. package/crates/enact-core/src/streaming/protection/encryption.rs +289 -0
  145. package/crates/enact-core/src/streaming/protection/mod.rs +43 -0
  146. package/crates/enact-core/src/streaming/protection/pii_protection.rs +243 -0
  147. package/crates/enact-core/src/streaming/protection/processor.rs +166 -0
  148. package/crates/enact-core/src/streaming/sse.rs +0 -0
  149. package/crates/enact-core/src/telemetry/exporter.rs +0 -0
  150. package/crates/enact-core/src/telemetry/init.rs +0 -0
  151. package/crates/enact-core/src/telemetry/mod.rs +49 -0
  152. package/crates/enact-core/src/telemetry/spans.rs +245 -0
  153. package/crates/enact-core/src/tool/agent_tool.rs +177 -0
  154. package/crates/enact-core/src/tool/browser/mod.rs +0 -0
  155. package/crates/enact-core/src/tool/browser/webdriver.rs +0 -0
  156. package/crates/enact-core/src/tool/cost.rs +247 -0
  157. package/crates/enact-core/src/tool/discovery.rs +0 -0
  158. package/crates/enact-core/src/tool/dispatcher.rs +347 -0
  159. package/crates/enact-core/src/tool/filesystem.rs +231 -0
  160. package/crates/enact-core/src/tool/function.rs +99 -0
  161. package/crates/enact-core/src/tool/git.rs +162 -0
  162. package/crates/enact-core/src/tool/http.rs +214 -0
  163. package/crates/enact-core/src/tool/mcp/client.rs +0 -0
  164. package/crates/enact-core/src/tool/mcp/mod.rs +0 -0
  165. package/crates/enact-core/src/tool/mod.rs +51 -0
  166. package/crates/enact-core/src/tool/reasoning/debugging.rs +0 -0
  167. package/crates/enact-core/src/tool/reasoning/mcts.rs +0 -0
  168. package/crates/enact-core/src/tool/reasoning/mod.rs +0 -0
  169. package/crates/enact-core/src/tool/reasoning/sequential.rs +0 -0
  170. package/crates/enact-core/src/tool/sandbox/dagger.rs +0 -0
  171. package/crates/enact-core/src/tool/sandbox/mod.rs +0 -0
  172. package/crates/enact-core/src/tool/shell.rs +147 -0
  173. package/crates/enact-core/src/tool/trait.rs +33 -0
  174. package/crates/enact-core/src/tool/web_search.rs +277 -0
  175. package/crates/enact-core/src/util/config.rs +0 -0
  176. package/crates/enact-core/src/util/errors.rs +0 -0
  177. package/crates/enact-core/src/util/mod.rs +6 -0
  178. package/crates/enact-core/tests/airgapped_e2e_test.rs +291 -0
  179. package/crates/enact-core/tests/e2e_agentic_loop.rs +119 -0
  180. package/crates/enact-core/tests/e2e_test.rs +259 -0
  181. package/crates/enact-core/tests/graph_test.rs +130 -0
  182. package/crates/enact-core/tests/stream_event_id_validation.rs +435 -0
  183. package/crates/enact-cron/Cargo.toml +28 -0
  184. package/crates/enact-cron/src/lib.rs +44 -0
  185. package/crates/enact-cron/src/schedule.rs +156 -0
  186. package/crates/enact-cron/src/store.rs +589 -0
  187. package/crates/enact-cron/src/types.rs +148 -0
  188. package/crates/enact-gateway/Cargo.toml +31 -0
  189. package/crates/enact-gateway/README.md +30 -0
  190. package/crates/enact-gateway/examples/whatsapp-gateway-runner-mock.rs +59 -0
  191. package/crates/enact-gateway/examples/whatsapp-gateway.rs +42 -0
  192. package/crates/enact-gateway/src/lib.rs +582 -0
  193. package/crates/enact-mcp/Cargo.toml +24 -0
  194. package/crates/enact-mcp/src/lib.rs +178 -0
  195. package/crates/enact-memory/Cargo.toml +25 -0
  196. package/crates/enact-memory/src/backend.rs +20 -0
  197. package/crates/enact-memory/src/chunker.rs +230 -0
  198. package/crates/enact-memory/src/embeddings.rs +221 -0
  199. package/crates/enact-memory/src/lib.rs +67 -0
  200. package/crates/enact-memory/src/markdown.rs +127 -0
  201. package/crates/enact-memory/src/none.rs +61 -0
  202. package/crates/enact-memory/src/sqlite.rs +276 -0
  203. package/crates/enact-memory/src/traits.rs +65 -0
  204. package/crates/enact-memory/src/vector.rs +198 -0
  205. package/crates/enact-oauth/Cargo.toml +27 -0
  206. package/crates/enact-oauth/src/lib.rs +584 -0
  207. package/crates/enact-observability/Cargo.toml +22 -0
  208. package/crates/enact-observability/src/lib.rs +197 -0
  209. package/crates/enact-providers/Cargo.toml +33 -0
  210. package/crates/enact-providers/examples/hello-agent.rs +33 -0
  211. package/crates/enact-providers/src/anthropic.rs +182 -0
  212. package/crates/enact-providers/src/azure.rs +96 -0
  213. package/crates/enact-providers/src/bridge.rs +221 -0
  214. package/crates/enact-providers/src/gemini.rs +227 -0
  215. package/crates/enact-providers/src/http.rs +78 -0
  216. package/crates/enact-providers/src/lib.rs +53 -0
  217. package/crates/enact-providers/src/openai_compatible.rs +167 -0
  218. package/crates/enact-providers/src/openrouter.rs +33 -0
  219. package/crates/enact-runner/Cargo.toml +24 -0
  220. package/crates/enact-runner/README.md +76 -0
  221. package/crates/enact-runner/src/compaction.rs +225 -0
  222. package/crates/enact-runner/src/config.rs +118 -0
  223. package/crates/enact-runner/src/lib.rs +63 -0
  224. package/crates/enact-runner/src/loop_driver.rs +414 -0
  225. package/crates/enact-runner/src/parser.rs +421 -0
  226. package/crates/enact-runner/src/retry.rs +262 -0
  227. package/crates/enact-runner/tests/integration.rs +278 -0
  228. package/crates/enact-security/Cargo.toml +22 -0
  229. package/crates/enact-security/src/audit.rs +375 -0
  230. package/crates/enact-security/src/lib.rs +37 -0
  231. package/crates/enact-security/src/policy.rs +406 -0
  232. package/crates/enact-skills/Cargo.toml +25 -0
  233. package/crates/enact-skills/src/lib.rs +506 -0
  234. package/crates/enact-tools/Cargo.toml +22 -0
  235. package/crates/enact-tools/src/file_read.rs +166 -0
  236. package/crates/enact-tools/src/file_write.rs +216 -0
  237. package/crates/enact-tools/src/git_operations.rs +513 -0
  238. package/crates/enact-tools/src/http_request.rs +417 -0
  239. package/crates/enact-tools/src/lib.rs +104 -0
  240. package/crates/enact-tools/src/security.rs +227 -0
  241. package/crates/enact-tools/src/shell.rs +191 -0
  242. package/crates/enact-tools/src/traits.rs +159 -0
  243. package/docs/Makefile +74 -0
  244. package/docs/config.toml +62 -0
  245. package/docs/content/_index.md +174 -0
  246. package/docs/content/a2a/_index.md +431 -0
  247. package/docs/content/api/_index.md +323 -0
  248. package/docs/content/channels/_index.md +160 -0
  249. package/docs/content/channels/teams.md +205 -0
  250. package/docs/content/channels/telegram.md +182 -0
  251. package/docs/content/channels/webhook.md +423 -0
  252. package/docs/content/channels/whatsapp.md +240 -0
  253. package/docs/content/cli/_index.md +261 -0
  254. package/docs/content/concepts/_index.md +273 -0
  255. package/docs/content/configuration/_index.md +241 -0
  256. package/docs/content/cron/_index.md +248 -0
  257. package/docs/content/developers/_index.md +278 -0
  258. package/docs/content/getting-started/_index.md +180 -0
  259. package/docs/content/installation/_index.md +186 -0
  260. package/docs/content/installation/uninstall.md +101 -0
  261. package/docs/content/installation/updating.md +120 -0
  262. package/docs/content/mcp/_index.md +215 -0
  263. package/docs/content/memory/_index.md +163 -0
  264. package/docs/content/oauth/_index.md +515 -0
  265. package/docs/content/providers/_index.md +206 -0
  266. package/docs/content/roadmap/_index.md +199 -0
  267. package/docs/content/security/_index.md +219 -0
  268. package/docs/content/skills/_index.md +228 -0
  269. package/docs/content/tools/_index.md +485 -0
  270. package/docs/content/troubleshooting/_index.md +259 -0
  271. package/docs/content/yaml-schema/_index.md +294 -0
  272. package/docs/static/giallo-dark.css +91 -0
  273. package/docs/static/giallo-light.css +91 -0
  274. package/docs/themes/tanuki/.github/workflows/deploy.yml +44 -0
  275. package/docs/themes/tanuki/LICENSE +21 -0
  276. package/docs/themes/tanuki/README.md +166 -0
  277. package/docs/themes/tanuki/examples/blog/config.toml +58 -0
  278. package/docs/themes/tanuki/examples/blog/content/_index.md +4 -0
  279. package/docs/themes/tanuki/examples/blog/content/about.md +33 -0
  280. package/docs/themes/tanuki/examples/blog/content/blog/_index.md +7 -0
  281. package/docs/themes/tanuki/examples/blog/content/blog/api-design-best-practices.md +245 -0
  282. package/docs/themes/tanuki/examples/blog/content/blog/building-accessible-websites.md +147 -0
  283. package/docs/themes/tanuki/examples/blog/content/blog/css-grid-vs-flexbox.md +165 -0
  284. package/docs/themes/tanuki/examples/blog/content/blog/customizing-catppuccin-colors.md +137 -0
  285. package/docs/themes/tanuki/examples/blog/content/blog/dark-mode-best-practices.md +82 -0
  286. package/docs/themes/tanuki/examples/blog/content/blog/docker-essentials.md +301 -0
  287. package/docs/themes/tanuki/examples/blog/content/blog/getting-started-with-zola.md +129 -0
  288. package/docs/themes/tanuki/examples/blog/content/blog/git-workflow-for-content.md +112 -0
  289. package/docs/themes/tanuki/examples/blog/content/blog/introduction-to-webassembly.md +183 -0
  290. package/docs/themes/tanuki/examples/blog/content/blog/modern-javascript-features.md +234 -0
  291. package/docs/themes/tanuki/examples/blog/content/blog/testing-strategies.md +311 -0
  292. package/docs/themes/tanuki/examples/blog/content/blog/typography-for-developers.md +104 -0
  293. package/docs/themes/tanuki/examples/blog/content/blog/welcome-to-tanuki.md +67 -0
  294. package/docs/themes/tanuki/examples/blog/content/blog/why-static-sites.md +85 -0
  295. package/docs/themes/tanuki/examples/blog/content/projects.md +64 -0
  296. package/docs/themes/tanuki/examples/book/config.toml +17 -0
  297. package/docs/themes/tanuki/examples/book/content/_index.md +12 -0
  298. package/docs/themes/tanuki/examples/book/content/chapter-1.md +90 -0
  299. package/docs/themes/tanuki/examples/book/content/chapter-2.md +143 -0
  300. package/docs/themes/tanuki/examples/book/content/chapter-3.md +217 -0
  301. package/docs/themes/tanuki/examples/book/content/chapter-4.md +224 -0
  302. package/docs/themes/tanuki/examples/book/content/chapter-5.md +297 -0
  303. package/docs/themes/tanuki/examples/book/content/print.md +6 -0
  304. package/docs/themes/tanuki/examples/docs/config.toml +28 -0
  305. package/docs/themes/tanuki/examples/docs/content/_index.md +20 -0
  306. package/docs/themes/tanuki/examples/docs/content/components.md +156 -0
  307. package/docs/themes/tanuki/examples/docs/content/configuration.md +94 -0
  308. package/docs/themes/tanuki/examples/docs/content/customization.md +202 -0
  309. package/docs/themes/tanuki/examples/docs/content/deployment.md +204 -0
  310. package/docs/themes/tanuki/examples/docs/content/installation.md +59 -0
  311. package/docs/themes/tanuki/examples/docs/content/print.md +6 -0
  312. package/docs/themes/tanuki/examples/docs/static/img/tanuki-icon.avif +0 -0
  313. package/docs/themes/tanuki/examples/index.html +2104 -0
  314. package/docs/themes/tanuki/mise.toml +108 -0
  315. package/docs/themes/tanuki/sass/base/_catppuccin.scss +164 -0
  316. package/docs/themes/tanuki/sass/base/_fonts.scss +64 -0
  317. package/docs/themes/tanuki/sass/base/_reset.scss +152 -0
  318. package/docs/themes/tanuki/sass/base/_typography.scss +523 -0
  319. package/docs/themes/tanuki/sass/components/_buttons.scss +209 -0
  320. package/docs/themes/tanuki/sass/components/_code.scss +457 -0
  321. package/docs/themes/tanuki/sass/components/_landing.scss +633 -0
  322. package/docs/themes/tanuki/sass/components/_layout.scss +294 -0
  323. package/docs/themes/tanuki/sass/components/_navigation.scss +1200 -0
  324. package/docs/themes/tanuki/sass/components/_print.scss +237 -0
  325. package/docs/themes/tanuki/sass/components/_search.scss +224 -0
  326. package/docs/themes/tanuki/sass/components/_sidebar.scss +473 -0
  327. package/docs/themes/tanuki/sass/components/_theme-toggle.scss +186 -0
  328. package/docs/themes/tanuki/sass/modes/_blog.scss +366 -0
  329. package/docs/themes/tanuki/sass/modes/_product.scss +875 -0
  330. package/docs/themes/tanuki/sass/modes/_raskell.scss +1696 -0
  331. package/docs/themes/tanuki/sass/patterns/_buttons.scss +183 -0
  332. package/docs/themes/tanuki/sass/patterns/_cards.scss +144 -0
  333. package/docs/themes/tanuki/sass/patterns/_index.scss +9 -0
  334. package/docs/themes/tanuki/sass/patterns/_lists.scss +259 -0
  335. package/docs/themes/tanuki/sass/patterns/_sections.scss +243 -0
  336. package/docs/themes/tanuki/sass/style.scss +47 -0
  337. package/docs/themes/tanuki/sass/tokens/_colors.scss +139 -0
  338. package/docs/themes/tanuki/sass/tokens/_spacing.scss +100 -0
  339. package/docs/themes/tanuki/sass/tokens/_typography.scss +186 -0
  340. package/docs/themes/tanuki/screenshot.png +0 -0
  341. package/docs/themes/tanuki/sentinel.kdl +59 -0
  342. package/docs/themes/tanuki/static/elasticlunr.min.js +10 -0
  343. package/docs/themes/tanuki/static/fonts/GEIST-LICENSE.txt +92 -0
  344. package/docs/themes/tanuki/static/fonts/Geist-Variable.woff2 +0 -0
  345. package/docs/themes/tanuki/static/fonts/GeistMono-Variable.woff2 +0 -0
  346. package/docs/themes/tanuki/static/img/tanuki-icon.avif +0 -0
  347. package/docs/themes/tanuki/static/img/tanuki-icon.png +0 -0
  348. package/docs/themes/tanuki/static/js/anchors.js +18 -0
  349. package/docs/themes/tanuki/static/js/app.js +274 -0
  350. package/docs/themes/tanuki/static/js/code.js +394 -0
  351. package/docs/themes/tanuki/static/js/navigation.js +778 -0
  352. package/docs/themes/tanuki/static/js/scroll-to-top.js +33 -0
  353. package/docs/themes/tanuki/static/js/search-raskell.js +240 -0
  354. package/docs/themes/tanuki/static/js/search.js +215 -0
  355. package/docs/themes/tanuki/static/js/theme.js +169 -0
  356. package/docs/themes/tanuki/static/syntax-dark.css +151 -0
  357. package/docs/themes/tanuki/static/syntax-light.css +151 -0
  358. package/docs/themes/tanuki/static/wasm/sentinel_playground_wasm.js +486 -0
  359. package/docs/themes/tanuki/static/wasm/sentinel_playground_wasm_bg.wasm +0 -0
  360. package/docs/themes/tanuki/templates/404.html +52 -0
  361. package/docs/themes/tanuki/templates/base.html +428 -0
  362. package/docs/themes/tanuki/templates/blog.html +66 -0
  363. package/docs/themes/tanuki/templates/home.html +108 -0
  364. package/docs/themes/tanuki/templates/index.html +178 -0
  365. package/docs/themes/tanuki/templates/landing.html +168 -0
  366. package/docs/themes/tanuki/templates/macros/nav.html +128 -0
  367. package/docs/themes/tanuki/templates/macros/posts.html +101 -0
  368. package/docs/themes/tanuki/templates/macros/ui.html +159 -0
  369. package/docs/themes/tanuki/templates/page.html +135 -0
  370. package/docs/themes/tanuki/templates/partials/footer.html +38 -0
  371. package/docs/themes/tanuki/templates/partials/header.html +366 -0
  372. package/docs/themes/tanuki/templates/partials/nav-buttons.html +55 -0
  373. package/docs/themes/tanuki/templates/partials/nav-overlay.html +81 -0
  374. package/docs/themes/tanuki/templates/partials/page-toc-panel.html +43 -0
  375. package/docs/themes/tanuki/templates/partials/search.html +52 -0
  376. package/docs/themes/tanuki/templates/partials/sidebar.html +107 -0
  377. package/docs/themes/tanuki/templates/partials/theme-toggle.html +35 -0
  378. package/docs/themes/tanuki/templates/partials/toc-overlay.html +146 -0
  379. package/docs/themes/tanuki/templates/partials/version-picker.html +38 -0
  380. package/docs/themes/tanuki/templates/print.html +244 -0
  381. package/docs/themes/tanuki/templates/section.html +186 -0
  382. package/docs/themes/tanuki/templates/taxonomy_list.html +18 -0
  383. package/docs/themes/tanuki/templates/taxonomy_single.html +31 -0
  384. package/docs/themes/tanuki/theme.toml +58 -0
  385. package/examples/hello-agent.rs +55 -0
  386. package/package.json +36 -0
  387. package/proto/config.proto +60 -0
  388. package/proto/events.proto +0 -0
  389. package/proto/runtime.proto +215 -0
@@ -0,0 +1,435 @@
1
+ //! Unit tests for stream event ID signature validation
2
+ //!
3
+ //! CRITICAL: All stream events MUST carry full ID signatures (executionId, stepId, parentId, parentType, tenantId)
4
+ //! to enable proper UI rendering and agent graph tracking, especially when multiple agents run simultaneously.
5
+ //!
6
+ //! This test ensures that:
7
+ //! 1. All events have executionId
8
+ //! 2. Step-related events have stepId
9
+ //! 3. All events have parentId and parentType for proper hierarchy tracking
10
+ //! 4. All events have tenantId for multi-tenancy support
11
+ //! 5. Text deltas have stepId when part of a step
12
+ //! 6. Artifacts have artifactId and stepId
13
+
14
+ use enact_core::streaming::StreamEvent;
15
+
16
+ /// Validate that a stream event has all required ID fields
17
+ fn validate_event_ids(event: &StreamEvent) -> Result<(), Vec<String>> {
18
+ let mut missing = Vec::new();
19
+
20
+ match event {
21
+ StreamEvent::ExecutionStart { execution_id, .. } => {
22
+ if execution_id.is_empty() {
23
+ missing.push("executionId".to_string());
24
+ }
25
+ // parent_id and parent_type are optional for execution start
26
+ }
27
+
28
+ StreamEvent::ExecutionEnd { execution_id, .. }
29
+ | StreamEvent::ExecutionFailed { execution_id, .. }
30
+ | StreamEvent::ExecutionPaused { execution_id, .. }
31
+ | StreamEvent::ExecutionResumed { execution_id, .. }
32
+ | StreamEvent::ExecutionCancelled { execution_id, .. } => {
33
+ if execution_id.is_empty() {
34
+ missing.push("executionId".to_string());
35
+ }
36
+ }
37
+
38
+ StreamEvent::StepStart {
39
+ execution_id,
40
+ step_id,
41
+ ..
42
+ } => {
43
+ if execution_id.is_empty() {
44
+ missing.push("executionId".to_string());
45
+ }
46
+ if step_id.is_empty() {
47
+ missing.push("stepId".to_string());
48
+ }
49
+ }
50
+
51
+ StreamEvent::StepEnd {
52
+ execution_id,
53
+ step_id,
54
+ ..
55
+ }
56
+ | StreamEvent::StepFailed {
57
+ execution_id,
58
+ step_id,
59
+ ..
60
+ } => {
61
+ if execution_id.is_empty() {
62
+ missing.push("executionId".to_string());
63
+ }
64
+ if step_id.is_empty() {
65
+ missing.push("stepId".to_string());
66
+ }
67
+ }
68
+
69
+ StreamEvent::ArtifactCreated {
70
+ execution_id,
71
+ step_id,
72
+ artifact_id,
73
+ ..
74
+ } => {
75
+ if execution_id.is_empty() {
76
+ missing.push("executionId".to_string());
77
+ }
78
+ if step_id.is_empty() {
79
+ missing.push("stepId".to_string());
80
+ }
81
+ if artifact_id.is_empty() {
82
+ missing.push("artifactId".to_string());
83
+ }
84
+ }
85
+
86
+ StreamEvent::TextDelta { id, .. } => {
87
+ if id.is_empty() {
88
+ missing.push("id".to_string());
89
+ }
90
+ // NOTE: TextDelta currently doesn't have executionId, stepId, parentId, parentType, tenantId
91
+ // This is a known issue that needs to be fixed!
92
+ missing.push("executionId".to_string());
93
+ missing.push("stepId (when part of step)".to_string());
94
+ missing.push("parentId".to_string());
95
+ missing.push("parentType".to_string());
96
+ missing.push("tenantId".to_string());
97
+ }
98
+
99
+ StreamEvent::TextStart {
100
+ id, execution_id, ..
101
+ } => {
102
+ if id.is_empty() {
103
+ missing.push("id".to_string());
104
+ }
105
+ // execution_id is optional, but should be present for proper tracking
106
+ if execution_id.is_none() {
107
+ missing.push("executionId (recommended)".to_string());
108
+ }
109
+ }
110
+
111
+ StreamEvent::TextEnd { id, .. } => {
112
+ if id.is_empty() {
113
+ missing.push("id".to_string());
114
+ }
115
+ }
116
+
117
+ StreamEvent::StartStep { step_id, .. } => {
118
+ // step_id is optional, but should be present
119
+ if step_id.is_none() {
120
+ missing.push("stepId (recommended)".to_string());
121
+ }
122
+ }
123
+
124
+ StreamEvent::FinishStep { step_id, .. } => {
125
+ // step_id is optional, but should be present
126
+ if step_id.is_none() {
127
+ missing.push("stepId (recommended)".to_string());
128
+ }
129
+ }
130
+
131
+ StreamEvent::ToolInputStart { tool_call_id, .. }
132
+ | StreamEvent::ToolInputDelta { tool_call_id, .. }
133
+ | StreamEvent::ToolInputAvailable { tool_call_id, .. }
134
+ | StreamEvent::ToolOutputAvailable { tool_call_id, .. } => {
135
+ if tool_call_id.is_empty() {
136
+ missing.push("tool_call_id".to_string());
137
+ }
138
+ // NOTE: Tool events also need executionId, stepId, parentId, parentType, tenantId
139
+ missing.push("executionId".to_string());
140
+ missing.push("stepId (when part of step)".to_string());
141
+ missing.push("parentId".to_string());
142
+ missing.push("parentType".to_string());
143
+ missing.push("tenantId".to_string());
144
+ }
145
+
146
+ StreamEvent::Start {
147
+ message_id,
148
+ execution_id,
149
+ ..
150
+ } => {
151
+ if message_id.is_empty() {
152
+ missing.push("message_id".to_string());
153
+ }
154
+ // execution_id is optional, but should be present
155
+ if execution_id.is_none() {
156
+ missing.push("executionId (recommended)".to_string());
157
+ }
158
+ }
159
+
160
+ StreamEvent::Finish { message_id, .. } => {
161
+ if message_id.is_empty() {
162
+ missing.push("message_id".to_string());
163
+ }
164
+ }
165
+
166
+ StreamEvent::Error { .. } => {
167
+ // Error events may not have all IDs, but should have execution context
168
+ missing.push("executionId (recommended)".to_string());
169
+ }
170
+
171
+ StreamEvent::StateSnapshot { .. } => {
172
+ // State snapshots need full context
173
+ missing.push("executionId".to_string());
174
+ missing.push("stepId (when part of step)".to_string());
175
+ missing.push("parentId".to_string());
176
+ missing.push("parentType".to_string());
177
+ missing.push("tenantId".to_string());
178
+ }
179
+
180
+ StreamEvent::InboxMessage { .. } => {
181
+ // Inbox messages need full context
182
+ missing.push("executionId".to_string());
183
+ missing.push("stepId (when part of step)".to_string());
184
+ missing.push("parentId".to_string());
185
+ missing.push("parentType".to_string());
186
+ missing.push("tenantId".to_string());
187
+ }
188
+
189
+ StreamEvent::PolicyDecision { .. } => {
190
+ // Policy decisions need full context
191
+ missing.push("executionId".to_string());
192
+ missing.push("stepId (when part of step)".to_string());
193
+ missing.push("parentId".to_string());
194
+ missing.push("parentType".to_string());
195
+ missing.push("tenantId".to_string());
196
+ }
197
+
198
+ StreamEvent::StepDiscovered {
199
+ execution_id,
200
+ step_id,
201
+ ..
202
+ } => {
203
+ if execution_id.is_empty() {
204
+ missing.push("executionId".to_string());
205
+ }
206
+ if step_id.is_empty() {
207
+ missing.push("stepId".to_string());
208
+ }
209
+ }
210
+
211
+ StreamEvent::CheckpointSaved {
212
+ execution_id,
213
+ checkpoint_id,
214
+ ..
215
+ } => {
216
+ if execution_id.is_empty() {
217
+ missing.push("executionId".to_string());
218
+ }
219
+ if checkpoint_id.is_empty() {
220
+ missing.push("checkpointId".to_string());
221
+ }
222
+ }
223
+
224
+ StreamEvent::GoalEvaluated {
225
+ execution_id,
226
+ goal_id,
227
+ ..
228
+ } => {
229
+ if execution_id.is_empty() {
230
+ missing.push("executionId".to_string());
231
+ }
232
+ if goal_id.is_empty() {
233
+ missing.push("goalId".to_string());
234
+ }
235
+ }
236
+ }
237
+
238
+ if missing.is_empty() {
239
+ Ok(())
240
+ } else {
241
+ Err(missing)
242
+ }
243
+ }
244
+
245
+ #[test]
246
+ fn test_execution_start_has_execution_id() {
247
+ let event = StreamEvent::ExecutionStart {
248
+ execution_id: "exec_123".to_string(),
249
+ parent_id: Some("msg_456".to_string()),
250
+ parent_type: Some("user_message".to_string()),
251
+ timestamp: 1234567890,
252
+ };
253
+
254
+ let result = validate_event_ids(&event);
255
+ // ExecutionStart should pass (parent_id and parent_type are optional)
256
+ assert!(result.is_ok() || result.unwrap_err().is_empty());
257
+ }
258
+
259
+ #[test]
260
+ fn test_step_start_has_execution_id_and_step_id() {
261
+ let event = StreamEvent::StepStart {
262
+ execution_id: "exec_123".to_string(),
263
+ step_id: "step_456".to_string(),
264
+ step_type: "function".to_string(),
265
+ name: "Test Step".to_string(),
266
+ timestamp: 1234567890,
267
+ };
268
+
269
+ let result = validate_event_ids(&event);
270
+ assert!(
271
+ result.is_ok(),
272
+ "StepStart should have executionId and stepId"
273
+ );
274
+ }
275
+
276
+ #[test]
277
+ fn test_step_end_has_execution_id_and_step_id() {
278
+ let event = StreamEvent::StepEnd {
279
+ execution_id: "exec_123".to_string(),
280
+ step_id: "step_456".to_string(),
281
+ output: None,
282
+ duration_ms: 100,
283
+ timestamp: 1234567890,
284
+ };
285
+
286
+ let result = validate_event_ids(&event);
287
+ assert!(result.is_ok(), "StepEnd should have executionId and stepId");
288
+ }
289
+
290
+ #[test]
291
+ fn test_artifact_created_has_all_ids() {
292
+ let event = StreamEvent::ArtifactCreated {
293
+ execution_id: "exec_123".to_string(),
294
+ step_id: "step_456".to_string(),
295
+ artifact_id: "artifact_789".to_string(),
296
+ artifact_type: "evidence".to_string(),
297
+ timestamp: 1234567890,
298
+ };
299
+
300
+ let result = validate_event_ids(&event);
301
+ assert!(
302
+ result.is_ok(),
303
+ "ArtifactCreated should have executionId, stepId, and artifactId"
304
+ );
305
+ }
306
+
307
+ #[test]
308
+ fn test_text_delta_missing_ids() {
309
+ let event = StreamEvent::TextDelta {
310
+ id: "text_123".to_string(),
311
+ delta: "Hello".to_string(),
312
+ };
313
+
314
+ let result = validate_event_ids(&event);
315
+ // TextDelta currently doesn't have executionId, stepId, etc. - this test documents the issue
316
+ assert!(
317
+ result.is_err(),
318
+ "TextDelta should fail validation until it includes executionId, stepId, parentId, parentType, tenantId"
319
+ );
320
+
321
+ let missing = result.unwrap_err();
322
+ assert!(
323
+ missing.contains(&"executionId".to_string()),
324
+ "TextDelta should report missing executionId"
325
+ );
326
+ assert!(
327
+ missing.contains(&"stepId (when part of step)".to_string()),
328
+ "TextDelta should report missing stepId when part of step"
329
+ );
330
+ }
331
+
332
+ #[test]
333
+ fn test_tool_events_missing_ids() {
334
+ let event = StreamEvent::ToolInputStart {
335
+ tool_call_id: "tool_123".to_string(),
336
+ tool_name: "get_weather".to_string(),
337
+ };
338
+
339
+ let result = validate_event_ids(&event);
340
+ // Tool events currently don't have executionId, stepId, etc. - this test documents the issue
341
+ assert!(
342
+ result.is_err(),
343
+ "ToolInputStart should fail validation until it includes executionId, stepId, parentId, parentType, tenantId"
344
+ );
345
+
346
+ let missing = result.unwrap_err();
347
+ assert!(
348
+ missing.contains(&"executionId".to_string()),
349
+ "ToolInputStart should report missing executionId"
350
+ );
351
+ }
352
+
353
+ #[test]
354
+ fn test_execution_end_has_execution_id() {
355
+ let event = StreamEvent::ExecutionEnd {
356
+ execution_id: "exec_123".to_string(),
357
+ final_output: None,
358
+ duration_ms: 1000,
359
+ timestamp: 1234567890,
360
+ };
361
+
362
+ let result = validate_event_ids(&event);
363
+ assert!(result.is_ok(), "ExecutionEnd should have executionId");
364
+ }
365
+
366
+ #[test]
367
+ fn test_step_failed_has_execution_id_and_step_id() {
368
+ use enact_core::kernel::{ExecutionError, ExecutionErrorCategory};
369
+ let event = StreamEvent::StepFailed {
370
+ execution_id: "exec_123".to_string(),
371
+ step_id: "step_456".to_string(),
372
+ error: ExecutionError::new(ExecutionErrorCategory::KernelInternal, "Test error"),
373
+ timestamp: 1234567890,
374
+ };
375
+
376
+ let result = validate_event_ids(&event);
377
+ assert!(
378
+ result.is_ok(),
379
+ "StepFailed should have executionId and stepId"
380
+ );
381
+ }
382
+
383
+ /// Test that validates the requirement: ALL stream events must have complete ID signatures
384
+ /// This test will fail until the StreamEvent enum is updated to include all required fields
385
+ #[test]
386
+ #[ignore] // Ignore until StreamEvent is fixed to include all required IDs
387
+ fn test_all_events_have_complete_id_signatures() {
388
+ // This test documents the requirement that ALL events must have:
389
+ // - executionId (required for all events)
390
+ // - stepId (required for step-related events)
391
+ // - parentId (required for hierarchy tracking)
392
+ // - parentType (required for hierarchy tracking)
393
+ // - tenantId (required for multi-tenancy)
394
+ //
395
+ // Currently, many events are missing these fields. This test will pass once
396
+ // the StreamEvent enum is updated to include all required fields.
397
+
398
+ let test_events = vec![
399
+ StreamEvent::ExecutionStart {
400
+ execution_id: "exec_123".to_string(),
401
+ parent_id: Some("msg_456".to_string()),
402
+ parent_type: Some("user_message".to_string()),
403
+ timestamp: 1234567890,
404
+ },
405
+ StreamEvent::StepStart {
406
+ execution_id: "exec_123".to_string(),
407
+ step_id: "step_456".to_string(),
408
+ step_type: "function".to_string(),
409
+ name: "Test".to_string(),
410
+ timestamp: 1234567890,
411
+ },
412
+ StreamEvent::TextDelta {
413
+ id: "text_123".to_string(),
414
+ delta: "Hello".to_string(),
415
+ },
416
+ StreamEvent::ArtifactCreated {
417
+ execution_id: "exec_123".to_string(),
418
+ step_id: "step_456".to_string(),
419
+ artifact_id: "artifact_789".to_string(),
420
+ artifact_type: "evidence".to_string(),
421
+ timestamp: 1234567890,
422
+ },
423
+ ];
424
+
425
+ for (index, event) in test_events.iter().enumerate() {
426
+ let result = validate_event_ids(event);
427
+ assert!(
428
+ result.is_ok(),
429
+ "Event {} ({:?}) should have complete ID signatures. Missing: {:?}",
430
+ index,
431
+ event,
432
+ result.unwrap_err()
433
+ );
434
+ }
435
+ }
@@ -0,0 +1,28 @@
1
+ [package]
2
+ name = "enact-cron"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ license.workspace = true
6
+ description = "Cron scheduling system for Enact agents"
7
+ repository.workspace = true
8
+ homepage.workspace = true
9
+ keywords = ["cron", "scheduler", "scheduling", "timer"]
10
+ categories.workspace = true
11
+
12
+ [dependencies]
13
+ enact-core.workspace = true
14
+ anyhow.workspace = true
15
+ async-trait.workspace = true
16
+ tokio.workspace = true
17
+ serde.workspace = true
18
+ serde_json.workspace = true
19
+ tracing.workspace = true
20
+ chrono = { version = "0.4", features = ["serde"] }
21
+ cron = "0.15"
22
+ chrono-tz = "0.10"
23
+ rusqlite = { version = "0.32", features = ["bundled"] }
24
+ uuid = { version = "1.0", features = ["v4"] }
25
+ futures-util = "0.3"
26
+
27
+ [dev-dependencies]
28
+ tempfile.workspace = true
@@ -0,0 +1,44 @@
1
+ //! Cron scheduling system for Enact agents
2
+ //!
3
+ //! This crate provides a complete cron scheduling system with:
4
+ //! - Standard cron expressions (5, 6, or 7 fields)
5
+ //! - Timezone support
6
+ //! - One-shot and interval schedules
7
+ //! - SQLite-based persistence
8
+ //! - Job history tracking
9
+
10
+ pub mod schedule;
11
+ pub mod store;
12
+ pub mod types;
13
+
14
+ pub use schedule::{next_run_for_schedule, normalize_expression, parse_delay, validate_schedule};
15
+ pub use store::CronStore;
16
+ pub use types::{CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType, Schedule, SessionTarget};
17
+
18
+ #[cfg(test)]
19
+ mod tests {
20
+ use super::*;
21
+ use tempfile::TempDir;
22
+
23
+ #[test]
24
+ fn cron_store_works() {
25
+ let tmp = TempDir::new().unwrap();
26
+ let store = CronStore::new(tmp.path(), 100, 10).unwrap();
27
+
28
+ // Add a job
29
+ let job = store.add_job("0 9 * * *", "echo good morning").unwrap();
30
+ assert_eq!(job.command, "echo good morning");
31
+
32
+ // List jobs
33
+ let jobs = store.list_jobs().unwrap();
34
+ assert_eq!(jobs.len(), 1);
35
+
36
+ // Get job
37
+ let fetched = store.get_job(&job.id).unwrap();
38
+ assert_eq!(fetched.id, job.id);
39
+
40
+ // Remove job
41
+ store.remove_job(&job.id).unwrap();
42
+ assert!(store.list_jobs().unwrap().is_empty());
43
+ }
44
+ }
@@ -0,0 +1,156 @@
1
+ //! Schedule parsing and next run calculation
2
+
3
+ use crate::types::Schedule;
4
+ use anyhow::{Context, Result};
5
+ use chrono::{DateTime, Duration as ChronoDuration, Utc};
6
+ use cron::Schedule as CronExprSchedule;
7
+ use std::str::FromStr;
8
+
9
+ /// Calculate the next run time for a schedule
10
+ pub fn next_run_for_schedule(schedule: &Schedule, from: DateTime<Utc>) -> Result<DateTime<Utc>> {
11
+ match schedule {
12
+ Schedule::Cron { expr, tz } => {
13
+ let normalized = normalize_expression(expr)?;
14
+ let cron = CronExprSchedule::from_str(&normalized)
15
+ .with_context(|| format!("Invalid cron expression: {expr}"))?;
16
+
17
+ if let Some(tz_name) = tz {
18
+ let timezone = chrono_tz::Tz::from_str(tz_name)
19
+ .with_context(|| format!("Invalid IANA timezone: {tz_name}"))?;
20
+ let localized_from = from.with_timezone(&timezone);
21
+ let next_local = cron.after(&localized_from).next().ok_or_else(|| {
22
+ anyhow::anyhow!("No future occurrence for expression: {expr}")
23
+ })?;
24
+ Ok(next_local.with_timezone(&Utc))
25
+ } else {
26
+ cron.after(&from)
27
+ .next()
28
+ .ok_or_else(|| anyhow::anyhow!("No future occurrence for expression: {expr}"))
29
+ }
30
+ }
31
+ Schedule::At { at } => Ok(*at),
32
+ Schedule::Every { every_ms } => {
33
+ if *every_ms == 0 {
34
+ anyhow::bail!("Invalid schedule: every_ms must be > 0");
35
+ }
36
+ let ms = i64::try_from(*every_ms).context("every_ms is too large")?;
37
+ let delta = ChronoDuration::milliseconds(ms);
38
+ from.checked_add_signed(delta)
39
+ .ok_or_else(|| anyhow::anyhow!("every_ms overflowed DateTime"))
40
+ }
41
+ }
42
+ }
43
+
44
+ /// Validate a schedule
45
+ pub fn validate_schedule(schedule: &Schedule, now: DateTime<Utc>) -> Result<()> {
46
+ match schedule {
47
+ Schedule::Cron { expr, .. } => {
48
+ let _ = normalize_expression(expr)?;
49
+ let _ = next_run_for_schedule(schedule, now)?;
50
+ Ok(())
51
+ }
52
+ Schedule::At { at } => {
53
+ if *at <= now {
54
+ anyhow::bail!("Invalid schedule: 'at' must be in the future");
55
+ }
56
+ Ok(())
57
+ }
58
+ Schedule::Every { every_ms } => {
59
+ if *every_ms == 0 {
60
+ anyhow::bail!("Invalid schedule: every_ms must be > 0");
61
+ }
62
+ Ok(())
63
+ }
64
+ }
65
+ }
66
+
67
+ /// Extract the cron expression from a schedule
68
+ pub fn schedule_cron_expression(schedule: &Schedule) -> Option<String> {
69
+ match schedule {
70
+ Schedule::Cron { expr, .. } => Some(expr.clone()),
71
+ _ => None,
72
+ }
73
+ }
74
+
75
+ /// Normalize a cron expression to 6 or 7 fields
76
+ pub fn normalize_expression(expression: &str) -> Result<String> {
77
+ let expression = expression.trim();
78
+ let field_count = expression.split_whitespace().count();
79
+
80
+ match field_count {
81
+ // standard crontab syntax: minute hour day month weekday
82
+ 5 => Ok(format!("0 {expression}")),
83
+ // crate-native syntax includes seconds (+ optional year)
84
+ 6 | 7 => Ok(expression.to_string()),
85
+ _ => anyhow::bail!(
86
+ "Invalid cron expression: {expression} (expected 5, 6, or 7 fields, got {field_count})"
87
+ ),
88
+ }
89
+ }
90
+
91
+ /// Parse a human-readable delay string (e.g., "5m", "1h", "30s")
92
+ pub fn parse_delay(input: &str) -> Result<ChronoDuration> {
93
+ let input = input.trim();
94
+ if input.is_empty() {
95
+ anyhow::bail!("delay must not be empty");
96
+ }
97
+ let split = input
98
+ .find(|c: char| !c.is_ascii_digit())
99
+ .unwrap_or(input.len());
100
+ let (num, unit) = input.split_at(split);
101
+ let amount: i64 = num.parse()?;
102
+ let unit = if unit.is_empty() { "m" } else { unit };
103
+ let duration = match unit {
104
+ "s" => ChronoDuration::seconds(amount),
105
+ "m" => ChronoDuration::minutes(amount),
106
+ "h" => ChronoDuration::hours(amount),
107
+ "d" => ChronoDuration::days(amount),
108
+ _ => anyhow::bail!("unsupported delay unit '{unit}', use s/m/h/d"),
109
+ };
110
+ Ok(duration)
111
+ }
112
+
113
+ #[cfg(test)]
114
+ mod tests {
115
+ use super::*;
116
+ use chrono::TimeZone;
117
+
118
+ #[test]
119
+ fn next_run_for_schedule_supports_every_and_at() {
120
+ let now = Utc::now();
121
+ let every = Schedule::Every { every_ms: 60_000 };
122
+ let next = next_run_for_schedule(&every, now).unwrap();
123
+ assert!(next > now);
124
+
125
+ let at = now + ChronoDuration::minutes(10);
126
+ let at_schedule = Schedule::At { at };
127
+ let next_at = next_run_for_schedule(&at_schedule, now).unwrap();
128
+ assert_eq!(next_at, at);
129
+ }
130
+
131
+ #[test]
132
+ fn next_run_for_schedule_supports_timezone() {
133
+ let from = Utc.with_ymd_and_hms(2026, 2, 16, 0, 0, 0).unwrap();
134
+ let schedule = Schedule::Cron {
135
+ expr: "0 9 * * *".into(),
136
+ tz: Some("America/Los_Angeles".into()),
137
+ };
138
+
139
+ let next = next_run_for_schedule(&schedule, from).unwrap();
140
+ assert_eq!(next, Utc.with_ymd_and_hms(2026, 2, 16, 17, 0, 0).unwrap());
141
+ }
142
+
143
+ #[test]
144
+ fn normalize_expression_handles_5_fields() {
145
+ let expr = normalize_expression("*/5 * * * *").unwrap();
146
+ assert_eq!(expr, "0 */5 * * * *");
147
+ }
148
+
149
+ #[test]
150
+ fn parse_delay_handles_units() {
151
+ assert_eq!(parse_delay("30s").unwrap(), ChronoDuration::seconds(30));
152
+ assert_eq!(parse_delay("5m").unwrap(), ChronoDuration::minutes(5));
153
+ assert_eq!(parse_delay("2h").unwrap(), ChronoDuration::hours(2));
154
+ assert_eq!(parse_delay("1d").unwrap(), ChronoDuration::days(1));
155
+ }
156
+ }