legate 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 (317) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +345 -0
  4. data/bin/legate +13 -0
  5. data/examples/00_quickstart.rb +51 -0
  6. data/examples/01_simple_agent.rb +105 -0
  7. data/examples/02_multi_tool_agent.rb +140 -0
  8. data/examples/03_custom_tool.rb +93 -0
  9. data/examples/04_agent_instructions.rb +84 -0
  10. data/examples/05_state_and_sessions.rb +91 -0
  11. data/examples/06_callbacks.rb +186 -0
  12. data/examples/07_async_jobs.rb +112 -0
  13. data/examples/08_loop_agent.rb +197 -0
  14. data/examples/09_sequential_workflow.rb +40 -0
  15. data/examples/10_parallel_workflow.rb +34 -0
  16. data/examples/11_agent_delegation.rb +24 -0
  17. data/examples/12_http_client_tool.rb +156 -0
  18. data/examples/13_authentication.rb +220 -0
  19. data/examples/14_mcp_client.rb +154 -0
  20. data/examples/15_mcp_server.rb +79 -0
  21. data/examples/16_webhooks.rb +91 -0
  22. data/examples/README_sequential_agents.md +164 -0
  23. data/examples/advanced/auth/cookie_auth_tool.rb +146 -0
  24. data/examples/advanced/auth/custom_auth_flows_example.rb +626 -0
  25. data/examples/advanced/auth/excon_middleware.rb +317 -0
  26. data/examples/advanced/auth/excon_middleware_auth.rb +399 -0
  27. data/examples/advanced/auth/fiber_auth_example.rb +281 -0
  28. data/examples/advanced/auth/fiber_oidc_example.rb +403 -0
  29. data/examples/advanced/auth/httpbin_bearer_tool.rb +159 -0
  30. data/examples/advanced/auth/oauth2_auth.rb +419 -0
  31. data/examples/advanced/auth/oidc_auth.rb +514 -0
  32. data/examples/advanced/auth/openweather_api.rb +251 -0
  33. data/examples/advanced/auth/openweather_tool.rb +153 -0
  34. data/examples/advanced/auth/query_param_middleware_test.rb +138 -0
  35. data/examples/advanced/auth/service_account.rb +135 -0
  36. data/examples/advanced/auth/test_with_httpbin.rb +202 -0
  37. data/examples/advanced/auth/token_lifecycle_example.rb +428 -0
  38. data/examples/advanced/callback_monitoring.rb +679 -0
  39. data/examples/advanced/mas/fixed_delegation_example.rb +191 -0
  40. data/examples/advanced/mas/loop_workflow.rb +28 -0
  41. data/examples/advanced/mas/mock_planner.rb +77 -0
  42. data/examples/advanced/mas/proper_delegation_example.rb +276 -0
  43. data/examples/advanced/mcp/legate_mcp_server_resource_example.rb +182 -0
  44. data/examples/advanced/mcp/mcp_resource_server_example.rb +309 -0
  45. data/examples/advanced/mcp/mcp_server_async.rb +76 -0
  46. data/examples/advanced/mcp/mcp_server_async_tools.rb +122 -0
  47. data/examples/advanced/mcp/mcp_server_legate_agent.rb +95 -0
  48. data/examples/advanced/mcp/mcp_server_rack.rb +89 -0
  49. data/examples/advanced/random_calculator.rb +104 -0
  50. data/examples/advanced/sleep_agent.rb +153 -0
  51. data/examples/advanced/webhooks/webhook_e2e_runner.rb +110 -0
  52. data/examples/advanced/webhooks/webhook_receiver_agent.rb +58 -0
  53. data/examples/advanced/workflows/task_refinement_loop_agent.rb +278 -0
  54. data/examples/advanced/workflows/travel_planner_auto_sequential.rb +444 -0
  55. data/examples/advanced/workflows/travel_planner_parallel.rb +656 -0
  56. data/examples/advanced/workflows/travel_planner_sequential.rb +512 -0
  57. data/examples/tools/oauth2_example.rb +136 -0
  58. data/examples/tools/sleepy_tool.rb +42 -0
  59. data/lib/legate/activity_log.rb +71 -0
  60. data/lib/legate/agent.rb +959 -0
  61. data/lib/legate/agent_code_generator.rb +185 -0
  62. data/lib/legate/agent_definition.rb +812 -0
  63. data/lib/legate/agentic/decision.rb +49 -0
  64. data/lib/legate/agentic/loop.rb +134 -0
  65. data/lib/legate/agentic.rb +5 -0
  66. data/lib/legate/agents/loop_agent.rb +248 -0
  67. data/lib/legate/agents/parallel_agent.rb +163 -0
  68. data/lib/legate/agents/sequential_agent.rb +190 -0
  69. data/lib/legate/agents.rb +14 -0
  70. data/lib/legate/auth/config.rb +148 -0
  71. data/lib/legate/auth/coordinator.rb +218 -0
  72. data/lib/legate/auth/coordinators/oauth2_coordinator.rb +99 -0
  73. data/lib/legate/auth/coordinators/oidc_coordinator.rb +68 -0
  74. data/lib/legate/auth/coordinators/service_account_coordinator.rb +122 -0
  75. data/lib/legate/auth/credential.rb +157 -0
  76. data/lib/legate/auth/encryption.rb +108 -0
  77. data/lib/legate/auth/error.rb +94 -0
  78. data/lib/legate/auth/exchanged_credential.rb +180 -0
  79. data/lib/legate/auth/excon_middleware.rb +285 -0
  80. data/lib/legate/auth/http_client_utils.rb +364 -0
  81. data/lib/legate/auth/manager.rb +531 -0
  82. data/lib/legate/auth/manager_store.rb +394 -0
  83. data/lib/legate/auth/middleware_factory.rb +290 -0
  84. data/lib/legate/auth/runner.rb +279 -0
  85. data/lib/legate/auth/scheme.rb +125 -0
  86. data/lib/legate/auth/schemes/api_key.rb +212 -0
  87. data/lib/legate/auth/schemes/google_service_account.rb +108 -0
  88. data/lib/legate/auth/schemes/http_bearer.rb +98 -0
  89. data/lib/legate/auth/schemes/oauth2.rb +396 -0
  90. data/lib/legate/auth/schemes/openid_connect.rb +346 -0
  91. data/lib/legate/auth/schemes/service_account.rb +388 -0
  92. data/lib/legate/auth/schemes.rb +40 -0
  93. data/lib/legate/auth/token_manager.rb +362 -0
  94. data/lib/legate/auth/token_store.rb +86 -0
  95. data/lib/legate/auth/tool_context_extension.rb +97 -0
  96. data/lib/legate/auth/tool_integration.rb +188 -0
  97. data/lib/legate/auth/url_guard.rb +81 -0
  98. data/lib/legate/auth.rb +453 -0
  99. data/lib/legate/callbacks/callback_context.rb +71 -0
  100. data/lib/legate/cli/agent_commands.rb +950 -0
  101. data/lib/legate/cli/auth_commands.rb +520 -0
  102. data/lib/legate/cli/base_command.rb +24 -0
  103. data/lib/legate/cli/deployment_commands.rb +934 -0
  104. data/lib/legate/cli/output_helper.rb +108 -0
  105. data/lib/legate/cli/session_commands.rb +138 -0
  106. data/lib/legate/cli/skaffold_commands.rb +223 -0
  107. data/lib/legate/cli/tool_commands.rb +261 -0
  108. data/lib/legate/cli/web_commands.rb +182 -0
  109. data/lib/legate/cli.rb +40 -0
  110. data/lib/legate/configuration/webhooks.rb +113 -0
  111. data/lib/legate/configuration.rb +39 -0
  112. data/lib/legate/definition_store.rb +23 -0
  113. data/lib/legate/errors.rb +118 -0
  114. data/lib/legate/event.rb +161 -0
  115. data/lib/legate/gemini_ai_beta_patch.rb +39 -0
  116. data/lib/legate/generators/agent_generator.rb +412 -0
  117. data/lib/legate/generators/code_validator.rb +48 -0
  118. data/lib/legate/generators/legate/install_generator.rb +35 -0
  119. data/lib/legate/generators/legate/templates/create_legate_tables.rb.tt +36 -0
  120. data/lib/legate/generators/legate/templates/initializer.rb +18 -0
  121. data/lib/legate/generators/runtime_tool_loader.rb +76 -0
  122. data/lib/legate/generators/tool_generator.rb +408 -0
  123. data/lib/legate/generators.rb +11 -0
  124. data/lib/legate/global_definition_registry.rb +506 -0
  125. data/lib/legate/global_tool_manager.rb +135 -0
  126. data/lib/legate/llm/adapter.rb +69 -0
  127. data/lib/legate/llm/gemini.rb +172 -0
  128. data/lib/legate/llm/ollama.rb +80 -0
  129. data/lib/legate/llm.rb +34 -0
  130. data/lib/legate/mcp/client.rb +320 -0
  131. data/lib/legate/mcp/connection/sse.rb +292 -0
  132. data/lib/legate/mcp/connection/stdio.rb +273 -0
  133. data/lib/legate/mcp/connection_manager.rb +103 -0
  134. data/lib/legate/mcp/server/legate_agent_adapter.rb +170 -0
  135. data/lib/legate/mcp/server/legate_direct_agent_adapter.rb +140 -0
  136. data/lib/legate/mcp/server/legate_tool_adapter.rb +119 -0
  137. data/lib/legate/mcp/tool_wrapper.rb +138 -0
  138. data/lib/legate/mcp/util/schema_converter.rb +134 -0
  139. data/lib/legate/mcp.rb +23 -0
  140. data/lib/legate/plan_executor.rb +375 -0
  141. data/lib/legate/planner.rb +839 -0
  142. data/lib/legate/rails/railtie.rb +43 -0
  143. data/lib/legate/rails.rb +9 -0
  144. data/lib/legate/redaction.rb +32 -0
  145. data/lib/legate/session.rb +299 -0
  146. data/lib/legate/session_service/active_record.rb +300 -0
  147. data/lib/legate/session_service/base.rb +68 -0
  148. data/lib/legate/session_service/event_broadcast.rb +74 -0
  149. data/lib/legate/session_service/in_memory.rb +188 -0
  150. data/lib/legate/tool/metadata_dsl.rb +122 -0
  151. data/lib/legate/tool.rb +276 -0
  152. data/lib/legate/tool_code_generator.rb +103 -0
  153. data/lib/legate/tool_context.rb +350 -0
  154. data/lib/legate/tool_loader.rb +39 -0
  155. data/lib/legate/tool_registry.rb +73 -0
  156. data/lib/legate/tool_result.rb +61 -0
  157. data/lib/legate/tools/agent_tool.rb +187 -0
  158. data/lib/legate/tools/base/http_client.rb +319 -0
  159. data/lib/legate/tools/base/safe_url.rb +56 -0
  160. data/lib/legate/tools/base_async_job_tool.rb +91 -0
  161. data/lib/legate/tools/calculator.rb +89 -0
  162. data/lib/legate/tools/cat_facts.rb +81 -0
  163. data/lib/legate/tools/check_job_status_tool.rb +48 -0
  164. data/lib/legate/tools/current_time_tool.rb +64 -0
  165. data/lib/legate/tools/echo.rb +43 -0
  166. data/lib/legate/tools/http_request_tool.rb +105 -0
  167. data/lib/legate/tools/random_number_tool.rb +64 -0
  168. data/lib/legate/tools/read_webpage_tool.rb +92 -0
  169. data/lib/legate/tools/sleepy_tool.rb +74 -0
  170. data/lib/legate/tools/webhook_tool.rb +146 -0
  171. data/lib/legate/version.rb +5 -0
  172. data/lib/legate/web/app.rb +984 -0
  173. data/lib/legate/web/public/css/main.css +4980 -0
  174. data/lib/legate/web/public/images/favicon-256.png +0 -0
  175. data/lib/legate/web/public/images/favicon-32.png +0 -0
  176. data/lib/legate/web/public/images/legate-logo-dark.png +0 -0
  177. data/lib/legate/web/public/images/legate-logo-light.png +0 -0
  178. data/lib/legate/web/public/js/legate.js +616 -0
  179. data/lib/legate/web/public/styles/main.scss +4402 -0
  180. data/lib/legate/web/routes/agent_authentication_routes.rb +530 -0
  181. data/lib/legate/web/routes/agent_definition_routes.rb +803 -0
  182. data/lib/legate/web/routes/agent_generator_routes.rb +80 -0
  183. data/lib/legate/web/routes/agent_interaction_routes.rb +734 -0
  184. data/lib/legate/web/routes/agent_runtime_routes.rb +323 -0
  185. data/lib/legate/web/routes/api_routes.rb +56 -0
  186. data/lib/legate/web/routes/authentication_routes.rb +1541 -0
  187. data/lib/legate/web/routes/core_routes.rb +111 -0
  188. data/lib/legate/web/routes/documentation_routes.rb +220 -0
  189. data/lib/legate/web/routes/tool_generator_routes.rb +81 -0
  190. data/lib/legate/web/routes/tools_ui_routes.rb +207 -0
  191. data/lib/legate/web/sass_compiler.rb +73 -0
  192. data/lib/legate/web/views/_active_session_info.slim +25 -0
  193. data/lib/legate/web/views/_activity_list.slim +55 -0
  194. data/lib/legate/web/views/_agent_card.slim +56 -0
  195. data/lib/legate/web/views/_agent_generator_modal.slim +382 -0
  196. data/lib/legate/web/views/_agent_status_controls.slim +71 -0
  197. data/lib/legate/web/views/_agent_tool_table.slim +74 -0
  198. data/lib/legate/web/views/_chat_message.slim +95 -0
  199. data/lib/legate/web/views/_display_agent_configuration.slim +26 -0
  200. data/lib/legate/web/views/_display_agent_description.slim +11 -0
  201. data/lib/legate/web/views/_display_agent_fallback.slim +15 -0
  202. data/lib/legate/web/views/_display_agent_hierarchy.slim +93 -0
  203. data/lib/legate/web/views/_display_agent_instruction.slim +17 -0
  204. data/lib/legate/web/views/_display_agent_mcp.slim +13 -0
  205. data/lib/legate/web/views/_display_agent_model.slim +17 -0
  206. data/lib/legate/web/views/_display_agent_name.slim +42 -0
  207. data/lib/legate/web/views/_display_agent_output_key.slim +26 -0
  208. data/lib/legate/web/views/_display_agent_type.slim +65 -0
  209. data/lib/legate/web/views/_edit_agent_configuration.slim +74 -0
  210. data/lib/legate/web/views/_edit_agent_description.slim +16 -0
  211. data/lib/legate/web/views/_edit_agent_fallback.slim +25 -0
  212. data/lib/legate/web/views/_edit_agent_hierarchy.slim +98 -0
  213. data/lib/legate/web/views/_edit_agent_instruction.slim +49 -0
  214. data/lib/legate/web/views/_edit_agent_mcp.slim +33 -0
  215. data/lib/legate/web/views/_edit_agent_model.slim +23 -0
  216. data/lib/legate/web/views/_edit_agent_output_key.slim +36 -0
  217. data/lib/legate/web/views/_edit_agent_tools.slim +40 -0
  218. data/lib/legate/web/views/_edit_agent_type.slim +67 -0
  219. data/lib/legate/web/views/_session_error.slim +4 -0
  220. data/lib/legate/web/views/_skeleton.slim +69 -0
  221. data/lib/legate/web/views/_tool_card.slim +9 -0
  222. data/lib/legate/web/views/_tool_generator_modal.slim +311 -0
  223. data/lib/legate/web/views/agent.slim +436 -0
  224. data/lib/legate/web/views/agent_auth.slim +562 -0
  225. data/lib/legate/web/views/agents.slim +369 -0
  226. data/lib/legate/web/views/auth.slim +112 -0
  227. data/lib/legate/web/views/auth_credential_detail.slim +327 -0
  228. data/lib/legate/web/views/auth_credentials.slim +261 -0
  229. data/lib/legate/web/views/auth_debug.slim +94 -0
  230. data/lib/legate/web/views/auth_mapping_detail.slim +151 -0
  231. data/lib/legate/web/views/auth_mapping_new.slim +123 -0
  232. data/lib/legate/web/views/auth_mappings.slim +120 -0
  233. data/lib/legate/web/views/auth_scheme_detail.slim +274 -0
  234. data/lib/legate/web/views/auth_schemes.slim +259 -0
  235. data/lib/legate/web/views/auth_test.slim +418 -0
  236. data/lib/legate/web/views/chat.slim +192 -0
  237. data/lib/legate/web/views/docs_index.slim +105 -0
  238. data/lib/legate/web/views/docs_show.slim +105 -0
  239. data/lib/legate/web/views/error_404.slim +5 -0
  240. data/lib/legate/web/views/index.slim +148 -0
  241. data/lib/legate/web/views/layout.slim +144 -0
  242. data/lib/legate/web/views/tool_detail.slim +87 -0
  243. data/lib/legate/web/views/tools.slim +50 -0
  244. data/lib/legate/web/webhook_listener.rb +367 -0
  245. data/lib/legate/web.rb +9 -0
  246. data/lib/legate.rb +220 -0
  247. data/public/docs/advanced/callbacks.md +828 -0
  248. data/public/docs/advanced/mcp_schema_conversion.md +59 -0
  249. data/public/docs/authentication/api_reference/config.md +210 -0
  250. data/public/docs/authentication/api_reference/credential.md +246 -0
  251. data/public/docs/authentication/api_reference/encryption.md +218 -0
  252. data/public/docs/authentication/api_reference/exchanged_credential.md +271 -0
  253. data/public/docs/authentication/api_reference/excon_middleware.md +175 -0
  254. data/public/docs/authentication/api_reference/index.md +30 -0
  255. data/public/docs/authentication/api_reference/scheme.md +250 -0
  256. data/public/docs/authentication/api_reference/schemes/api_key.md +175 -0
  257. data/public/docs/authentication/api_reference/schemes/google_service_account.md +221 -0
  258. data/public/docs/authentication/api_reference/schemes/http_bearer.md +169 -0
  259. data/public/docs/authentication/api_reference/schemes/oauth2.md +343 -0
  260. data/public/docs/authentication/api_reference/schemes/oidc.md +73 -0
  261. data/public/docs/authentication/api_reference/schemes/openid_connect.md +311 -0
  262. data/public/docs/authentication/api_reference/schemes/service_account.md +287 -0
  263. data/public/docs/authentication/api_reference/token_manager.md +221 -0
  264. data/public/docs/authentication/api_reference/token_store.md +146 -0
  265. data/public/docs/authentication/api_reference/tool_context_extension.md +166 -0
  266. data/public/docs/authentication/guides/api_key.md +190 -0
  267. data/public/docs/authentication/guides/bearer.md +172 -0
  268. data/public/docs/authentication/guides/configuration.md +255 -0
  269. data/public/docs/authentication/guides/custom_flow.md +523 -0
  270. data/public/docs/authentication/guides/index.md +24 -0
  271. data/public/docs/authentication/guides/migration.md +435 -0
  272. data/public/docs/authentication/guides/oauth2.md +252 -0
  273. data/public/docs/authentication/guides/oidc.md +241 -0
  274. data/public/docs/authentication/guides/overview.md +155 -0
  275. data/public/docs/authentication/guides/secure_storage.md +301 -0
  276. data/public/docs/authentication/guides/service_account.md +228 -0
  277. data/public/docs/authentication/guides/token_lifecycle.md +295 -0
  278. data/public/docs/authentication/guides/web_ui_integration.md +504 -0
  279. data/public/docs/authentication/index.md +58 -0
  280. data/public/docs/authentication/troubleshooting/credential_storage.md +550 -0
  281. data/public/docs/authentication/troubleshooting/environment_variables.md +540 -0
  282. data/public/docs/authentication/troubleshooting/index.md +11 -0
  283. data/public/docs/authentication/troubleshooting/oauth2_issues.md +220 -0
  284. data/public/docs/authentication/troubleshooting/oidc_issues.md +412 -0
  285. data/public/docs/authentication/troubleshooting/token_refresh.md +338 -0
  286. data/public/docs/cli/legate_cli_usage.md +363 -0
  287. data/public/docs/core_concepts/legate_agent_lifecycle.md +124 -0
  288. data/public/docs/core_concepts/legate_architecture_overview.md +110 -0
  289. data/public/docs/core_concepts/legate_configuration.md +116 -0
  290. data/public/docs/core_concepts/legate_definition_store.md +102 -0
  291. data/public/docs/core_concepts/legate_planner.md +94 -0
  292. data/public/docs/core_concepts/legate_session_service.md +104 -0
  293. data/public/docs/error_handling/legate_error_handling.md +122 -0
  294. data/public/docs/examples.md +199 -0
  295. data/public/docs/getting_started.md +111 -0
  296. data/public/docs/guides/agentic_agents.md +137 -0
  297. data/public/docs/guides/ai_code_generators.md +437 -0
  298. data/public/docs/guides/auto_loading.md +326 -0
  299. data/public/docs/guides/configuring_agent_webhooks.md +219 -0
  300. data/public/docs/guides/http_client_usage.md +264 -0
  301. data/public/docs/guides/llm_providers.md +137 -0
  302. data/public/docs/guides/mcp_client_integration.md +232 -0
  303. data/public/docs/guides/mcp_server_exposure.md +206 -0
  304. data/public/docs/guides/rails_integration.md +128 -0
  305. data/public/docs/guides/sending_outbound_webhooks.md +227 -0
  306. data/public/docs/guides/streaming.md +112 -0
  307. data/public/docs/guides/webhooks.md +288 -0
  308. data/public/docs/introduction.md +51 -0
  309. data/public/docs/multi_agent_systems/advanced_features.md +57 -0
  310. data/public/docs/multi_agent_systems/agent_delegation.md +190 -0
  311. data/public/docs/multi_agent_systems/agent_hierarchy.md +49 -0
  312. data/public/docs/multi_agent_systems/state_management.md +47 -0
  313. data/public/docs/multi_agent_systems/workflow_agents.md +72 -0
  314. data/public/docs/tools/legate_built_in_tools.md +332 -0
  315. data/public/docs/tools/legate_tools_and_registry.md +263 -0
  316. data/public/docs/web_ui/legate_web_ui.md +137 -0
  317. metadata +823 -0
@@ -0,0 +1,43 @@
1
+ # File: lib/legate/rails/railtie.rb
2
+ # frozen_string_literal: true
3
+
4
+ # In a booted Rails app these are already loaded; require them explicitly so a
5
+ # cold `require 'legate/rails'` (e.g. in tests) doesn't trip on missing
6
+ # ActiveSupport core extensions that rails/railtie assumes.
7
+ require 'active_support'
8
+ require 'active_support/core_ext/module/delegation'
9
+ require 'rails/railtie'
10
+
11
+ module Legate
12
+ module Rails
13
+ # Integrates Legate with a host Rails application. Loaded via
14
+ # `require 'legate/rails'` (e.g. `gem 'legate', require: 'legate/rails'`),
15
+ # never by `require 'legate'`.
16
+ #
17
+ # It registers the `legate:install` generator and exposes `config.legate`.
18
+ # The wiring itself (pointing the session store at ActiveRecord, reading the
19
+ # API key) lives in the generated `config/initializers/legate.rb`, so apps
20
+ # stay in control — the Railtie forces nothing.
21
+ class Railtie < ::Rails::Railtie
22
+ config.legate = ::ActiveSupport::OrderedOptions.new
23
+
24
+ # Make `rails generate legate:install` discoverable.
25
+ generators do
26
+ require 'legate/generators/legate/install_generator'
27
+ end
28
+
29
+ # Optional convenience: if the app sets `config.legate.use_active_record_store`
30
+ # truthy, point Legate at the ActiveRecord store after initialization
31
+ # (when the DB connection is ready). Apps that prefer to do this themselves
32
+ # in the initializer simply leave it unset.
33
+ initializer 'legate.session_store' do |app|
34
+ if app.config.legate.use_active_record_store
35
+ require 'legate/session_service/active_record'
36
+ Legate.configure do |c|
37
+ c.session_service = Legate::SessionService::ActiveRecord.new
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,9 @@
1
+ # File: lib/legate/rails.rb
2
+ # frozen_string_literal: true
3
+
4
+ # Opt-in Rails integration. Require this (not just 'legate') inside a Rails app —
5
+ # e.g. in the Gemfile: `gem 'legate', require: 'legate/rails'`. It loads the
6
+ # Railtie, which registers the `legate:install` generator and the optional
7
+ # ActiveRecord session-store wiring. `require 'legate'` alone never touches Rails.
8
+ require 'legate'
9
+ require_relative 'rails/railtie'
@@ -0,0 +1,32 @@
1
+ # File: lib/legate/redaction.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Legate
5
+ # Strips secrets out of strings before they're logged or surfaced to users.
6
+ #
7
+ # LLM/HTTP client errors routinely embed the request URL — which for Gemini
8
+ # carries the API key as a `?key=...` query parameter — so error messages and
9
+ # logs must be scrubbed before they leave the process.
10
+ module Redaction
11
+ module_function
12
+
13
+ REPLACEMENT = '[REDACTED]'
14
+
15
+ # `key=`, `api_key=`, `access_token=`, `token=` query/form parameters.
16
+ SECRET_PARAM = /([?&](?:key|api[_-]?key|access_token|token)=)[^&\s"']+/i
17
+ # `Authorization: Bearer <token>`.
18
+ BEARER = %r{(Bearer\s+)[A-Za-z0-9\-._~+/]+=*}i
19
+ # Google API keys by their `AIza` prefix — a belt-and-suspenders catch even
20
+ # if the key shows up somewhere the patterns above don't match.
21
+ GOOGLE_KEY = /AIza[0-9A-Za-z\-_]{10,}/
22
+
23
+ # @param text [Object] anything stringifiable
24
+ # @return [String] the text with known secret shapes replaced
25
+ def redact(text)
26
+ text.to_s
27
+ .gsub(SECRET_PARAM, "\\1#{REPLACEMENT}")
28
+ .gsub(BEARER, "\\1#{REPLACEMENT}")
29
+ .gsub(GOOGLE_KEY, REPLACEMENT)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,299 @@
1
+ # File: lib/legate/session.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'concurrent'
5
+ require 'securerandom'
6
+ require 'json'
7
+ require_relative 'event' # Require the new Event class
8
+ require_relative 'errors'
9
+
10
+ module Legate
11
+ # Represents a single, ongoing conversation thread between a user and an agent system.
12
+ # It holds the history of interactions (Events) and temporary session-specific data (State).
13
+ class Session
14
+ VALID_PREFIXES = %w[user app temp].freeze
15
+
16
+ attr_reader :id, :app_name, :user_id, :created_at
17
+ attr_accessor :updated_at, :session_service
18
+
19
+ # Initializes a new session. Typically called by a SessionService.
20
+ # @param id [String] A unique identifier for the session (defaults to UUID).
21
+ # @param app_name [String] Identifier for the agent application.
22
+ # @param user_id [String] Identifier for the user initiating the session.
23
+ # @param initial_state [Hash] Optional initial data for the session state. Keys are symbolized.
24
+ # @param events [Array<Legate::Event>] Optional initial list of events (for reloading).
25
+ # @param session_service [Legate::SessionService::Base] The session service to use for persistence
26
+ def initialize(app_name:, user_id:, id: nil, initial_state: {}, events: [], session_service: nil)
27
+ @id = id || SecureRandom.uuid
28
+ @app_name = app_name
29
+ @user_id = user_id
30
+ @created_at = Time.now.utc # Use UTC
31
+ @updated_at = @created_at
32
+ @session_service = session_service
33
+ @mutex = Mutex.new
34
+ # Use Concurrent::Map for thread-safe state storage within the session object itself
35
+ @state = Concurrent::Map.new
36
+ # Ensure initial_state keys are symbols and manually populate the map
37
+ initial_state = {} unless initial_state.is_a?(Hash) # Ensure it's a hash
38
+ symbolized_initial_state = initial_state.transform_keys { |k|
39
+ begin
40
+ k.to_sym
41
+ rescue StandardError
42
+ k
43
+ end
44
+ }
45
+ symbolized_initial_state.each_pair do |key, value|
46
+ @state[key] = value
47
+ end
48
+ # Events array stores the history, ensure it's mutable if passed and validate contents
49
+ @events = events.map do |e|
50
+ if e.is_a?(Legate::Event)
51
+ e
52
+ else
53
+ (Legate.logger.warn("Session Init: Invalid event data skipped: #{e.inspect}")
54
+ nil)
55
+ end
56
+ end.compact
57
+ Legate.logger.debug("Session initialized: id=#{@id}, app=#{@app_name}, user=#{@user_id}, event_count=#{@events.size}")
58
+ end
59
+
60
+ # Thread-safe accessor for session events.
61
+ # @return [Array<Legate::Event>] A frozen snapshot of the event history.
62
+ def events
63
+ @mutex.synchronize { @events.dup.freeze }
64
+ end
65
+
66
+ # Provides access to the session's temporary state data.
67
+ # @return [Hash] The current session state (immutable view).
68
+ def state
69
+ # Ensure external modifications don't affect internal state directly.
70
+ @state.dup # Return a shallow copy
71
+ end
72
+
73
+ # Adds an event to the session's history and updates state if needed.
74
+ # @param event [Legate::Event] The event to add
75
+ # @return [Legate::Event, nil] The added event or nil if invalid
76
+ def add_event(event)
77
+ return nil unless event.is_a?(Legate::Event)
78
+
79
+ @mutex.synchronize do
80
+ @events << event
81
+ @updated_at = Time.now.utc
82
+ end
83
+
84
+ # Apply the event's state delta OUTSIDE the mutex: update_state may call the
85
+ # session service (save_scoped_state), and holding the non-reentrant @mutex
86
+ # across that external call risks deadlock if a service implementation calls
87
+ # back into the session (e.g. #events/#to_h). @state is a Concurrent::Map, so
88
+ # it is safe without the lock.
89
+ update_state(event.state_delta) if event.state_delta && !event.state_delta.empty?
90
+
91
+ Legate.logger.debug("Session #{@id}: Event added - Role: #{event.role}, Tool: #{event.tool_name || 'N/A'}")
92
+ event
93
+ end
94
+
95
+ # --- State Management Methods ---
96
+
97
+ # Gets a value from the session state.
98
+ # @param key [Symbol, String] The key to retrieve.
99
+ # @return [Object, nil] The value associated with the key, or nil if not found.
100
+ def get_state(key)
101
+ prefix, real_key = parse_key(key)
102
+ validate_prefix!(prefix) if prefix # match set/update/delete: reads and writes agree on what a key means
103
+ if prefix
104
+ @session_service&.load_scoped_state(scoped_namespace(prefix), real_key)
105
+ else
106
+ @state[real_key.to_sym]
107
+ end
108
+ end
109
+
110
+ # Sets a value in the session state.
111
+ # @param key [Symbol, String] The key to set.
112
+ # @param value [Object] The value to store.
113
+ # @raise [Legate::StateValidationError] If the value cannot be serialized
114
+ # @raise [Legate::InvalidPrefixError] If an invalid prefix is used
115
+ # @return [Object] The value that was set.
116
+ def set_state(key, value)
117
+ validate_serializable!(value)
118
+ prefix, real_key = parse_key(key)
119
+ validate_prefix!(prefix) if prefix
120
+
121
+ touch!
122
+ if prefix
123
+ @session_service&.save_scoped_state(scoped_namespace(prefix), real_key, value)
124
+ else
125
+ @state[real_key.to_sym] = value
126
+ end
127
+ value
128
+ end
129
+
130
+ # Merges a hash into the session state.
131
+ # @param hash [Hash] The hash to merge into the state.
132
+ # @raise [Legate::StateValidationError] If any value cannot be serialized
133
+ # @raise [Legate::InvalidPrefixError] If any key has an invalid prefix
134
+ def update_state(hash)
135
+ return unless hash.is_a?(Hash)
136
+
137
+ touch!
138
+ hash.each do |k, v|
139
+ validate_serializable!(v)
140
+ prefix, real_key = parse_key(k)
141
+ validate_prefix!(prefix) if prefix
142
+
143
+ if prefix
144
+ @session_service&.save_scoped_state(scoped_namespace(prefix), real_key, v)
145
+ else
146
+ @state[real_key.to_sym] = v
147
+ end
148
+ end
149
+ end
150
+
151
+ # Deletes a key from the session state.
152
+ # @param key [Symbol, String] The key to delete.
153
+ # @return [Object, nil] The value of the deleted key, or nil if not found.
154
+ def delete_state(key)
155
+ prefix, real_key = parse_key(key)
156
+ validate_prefix!(prefix) if prefix
157
+
158
+ touch!
159
+ if prefix
160
+ @session_service&.clear_scoped_state(scoped_namespace(prefix), real_key)
161
+ else
162
+ @state.delete(real_key.to_sym)
163
+ end
164
+ end
165
+
166
+ # Clears all key-value pairs from the session state.
167
+ def clear_state!
168
+ touch!
169
+ @state.clear
170
+ VALID_PREFIXES.each do |prefix|
171
+ @session_service&.clear_scoped_state(scoped_namespace(prefix), '*')
172
+ end
173
+ end
174
+
175
+ # Provides a plain Hash representation of the current state.
176
+ # @return [Hash] A copy of the session state.
177
+ def state_to_h
178
+ # Convert Concurrent::Map to a regular Hash
179
+ Hash[@state.to_enum(:each_pair).to_a]
180
+ end
181
+
182
+ # --- Serialization Helpers ---
183
+
184
+ # Serializes the entire session object to a Hash suitable for JSON conversion.
185
+ # @return [Hash] Hash representation of the session.
186
+ def to_h
187
+ serialized_events = @mutex.synchronize { @events.map(&:to_h) }
188
+ {
189
+ id: @id,
190
+ app_name: @app_name,
191
+ user_id: @user_id,
192
+ created_at: @created_at.iso8601(3),
193
+ updated_at: @updated_at.iso8601(3),
194
+ state: state_to_h,
195
+ events: serialized_events
196
+ }
197
+ end
198
+
199
+ # Deserializes session data from a hash into a Session object.
200
+ # @param hash [Hash] Hash containing session data (typically from JSON).
201
+ # @return [Legate::Session] A new Session object.
202
+ def self.from_h(hash)
203
+ sym_hash = hash.transform_keys(&:to_sym) # Ensure keys are symbols
204
+ events_data = sym_hash[:events] || []
205
+ events = events_data.map { |event_hash| Legate::Event.from_h(event_hash.transform_keys(&:to_sym)) }.compact
206
+
207
+ new(
208
+ id: sym_hash[:id],
209
+ app_name: sym_hash[:app_name],
210
+ user_id: sym_hash[:user_id],
211
+ initial_state: sym_hash[:state] || {},
212
+ events: events
213
+ ).tap do |session|
214
+ # Set timestamps after initialization
215
+ session.instance_variable_set(:@created_at, Time.iso8601(sym_hash[:created_at])) if sym_hash[:created_at]
216
+ session.instance_variable_set(:@updated_at, Time.iso8601(sym_hash[:updated_at])) if sym_hash[:updated_at]
217
+ end
218
+ rescue ArgumentError, TypeError => e
219
+ Legate.logger.error("Session.from_h: Failed to deserialize session data. Error: #{e.message}. Data: #{hash.inspect}")
220
+ nil # Return nil on deserialization error
221
+ end
222
+
223
+ private
224
+
225
+ # Records a state mutation by bumping @updated_at under the mutex, so the
226
+ # timestamp write doesn't race add_event's. The external scoped-state I/O is
227
+ # intentionally left outside the lock (see add_event).
228
+ def touch!
229
+ @mutex.synchronize { @updated_at = Time.now.utc }
230
+ end
231
+
232
+ def parse_key(key)
233
+ key_str = key.to_s
234
+ if key_str.include?(':')
235
+ prefix, real_key = key_str.split(':', 2)
236
+ [prefix, real_key]
237
+ else
238
+ [nil, key_str]
239
+ end
240
+ end
241
+
242
+ # Builds the identity-qualified namespace passed to the session service for a
243
+ # scoped-state prefix, so scoped state is isolated by owner instead of sharing
244
+ # one global slot per prefix:
245
+ # user → per (app, user), shared across that user's sessions
246
+ # app → per app, shared across the app's users
247
+ # temp → per session
248
+ # The session service treats this whole string as an opaque scope, so wildcard
249
+ # clears (clear_scoped_state(ns, '*')) only touch the caller's own subtree.
250
+ # @param prefix [String] One of VALID_PREFIXES.
251
+ # @return [String] The namespace string.
252
+ def scoped_namespace(prefix)
253
+ case prefix
254
+ when 'user' then "user:#{escape_ns(@app_name)}:#{escape_ns(@user_id)}"
255
+ when 'app' then "app:#{escape_ns(@app_name)}"
256
+ when 'temp' then "temp:#{escape_ns(@id)}"
257
+ else prefix
258
+ end
259
+ end
260
+
261
+ # Escapes ':' in identity components so a crafted app_name/user_id/id cannot
262
+ # traverse into another owner's namespace.
263
+ def escape_ns(component)
264
+ component.to_s.gsub(':', '%3A')
265
+ end
266
+
267
+ def validate_prefix!(prefix)
268
+ return if prefix.nil?
269
+ return if VALID_PREFIXES.include?(prefix)
270
+
271
+ raise Legate::InvalidPrefixError, "Invalid state key prefix: #{prefix}. Valid prefixes: #{VALID_PREFIXES.join(', ')}"
272
+ end
273
+
274
+ # Recursively checks if a value is JSON-serializable (basic types, nil, nested Hashes/Arrays thereof)
275
+ def is_json_serializable?(value)
276
+ case value
277
+ when String, Integer, TrueClass, FalseClass, NilClass
278
+ true
279
+ when Float
280
+ value.finite? # NaN/Infinity are Floats but JSON.generate raises on them — reject up front
281
+ when Hash
282
+ # Ensure all keys are strings/symbols and all values are serializable
283
+ value.all? { |k, v| (k.is_a?(String) || k.is_a?(Symbol)) && is_json_serializable?(v) }
284
+ when Array
285
+ value.all? { |v| is_json_serializable?(v) }
286
+ else
287
+ false # Other types (Time, Set, Object, etc.) are not serializable
288
+ end
289
+ end
290
+
291
+ # Raises error if value is not serializable
292
+ def validate_serializable!(value)
293
+ return if is_json_serializable?(value)
294
+
295
+ raise Legate::SerializationError,
296
+ "Value must be JSON-serializable (basic types, nil, Hash, Array): #{value.inspect}"
297
+ end
298
+ end # End Session class
299
+ end # End Legate module
@@ -0,0 +1,300 @@
1
+ # File: lib/legate/session_service/active_record.rb
2
+ # frozen_string_literal: true
3
+
4
+ # Opt-in: this file is NOT loaded by `require 'legate'`. Require it explicitly
5
+ # (and have ActiveRecord available + a connection established) to use a durable,
6
+ # ActiveRecord-backed session store. In Rails, `require 'legate/rails'` wires
7
+ # this up and `rails g legate:install` creates the migration.
8
+ require 'active_record'
9
+ require 'json'
10
+ require_relative 'base'
11
+ require_relative 'event_broadcast'
12
+ require_relative '../session'
13
+ require_relative '../event'
14
+
15
+ module Legate
16
+ module SessionService
17
+ # Durable session store backed by ActiveRecord (subsumes R2 persistence).
18
+ #
19
+ # Semantics mirror InMemory — repeated get_session within a process returns
20
+ # the same Session object so a run's mutations accumulate — but every
21
+ # mutation is written through to the database, so a restart or another
22
+ # process re-hydrates the committed history and state from rows.
23
+ #
24
+ # The host application owns the AR connection (Rails does this for you;
25
+ # standalone users call ActiveRecord::Base.establish_connection). Tables are
26
+ # created by the generated migration, or ad hoc via {.create_tables!}.
27
+ class ActiveRecord < Base
28
+ include EventBroadcast
29
+
30
+ # Abstract base so every Legate table shares the host's connection.
31
+ class Record < ::ActiveRecord::Base
32
+ self.abstract_class = true
33
+ end
34
+
35
+ # A persisted conversation (id is the Session UUID).
36
+ class SessionRecord < Record
37
+ self.table_name = 'legate_sessions'
38
+ serialize :state, coder: JSON
39
+ has_many :event_records, -> { order(:position) },
40
+ class_name: 'Legate::SessionService::ActiveRecord::EventRecord',
41
+ foreign_key: :legate_session_id, inverse_of: :session_record
42
+ end
43
+
44
+ # One appended event, ordered within its session by `position`.
45
+ class EventRecord < Record
46
+ self.table_name = 'legate_events'
47
+ serialize :content, coder: JSON
48
+ serialize :state_delta, coder: JSON
49
+ belongs_to :session_record,
50
+ class_name: 'Legate::SessionService::ActiveRecord::SessionRecord',
51
+ foreign_key: :legate_session_id, inverse_of: :event_records
52
+ end
53
+
54
+ # Scoped (user:/app:/temp:) state, keyed by the namespaced scope + key.
55
+ class ScopedStateRecord < Record
56
+ self.table_name = 'legate_scoped_states'
57
+ serialize :value, coder: JSON
58
+ end
59
+
60
+ # Creates the three Legate tables if absent. Convenience for tests and
61
+ # standalone (non-migration) setups; Rails apps use the generated migration.
62
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
63
+ def self.create_tables!(connection: Record.connection)
64
+ unless connection.table_exists?(:legate_sessions)
65
+ connection.create_table :legate_sessions, id: :string do |t|
66
+ t.string :app_name
67
+ t.string :user_id
68
+ t.text :state
69
+ t.timestamps
70
+ end
71
+ end
72
+
73
+ unless connection.table_exists?(:legate_events)
74
+ connection.create_table :legate_events do |t|
75
+ t.string :legate_session_id, null: false, index: true
76
+ t.integer :position, null: false, default: 0
77
+ t.string :role
78
+ t.text :content
79
+ t.string :tool_name
80
+ t.text :state_delta
81
+ t.string :event_timestamp
82
+ t.string :event_id
83
+ t.timestamps
84
+ end
85
+ end
86
+
87
+ return if connection.table_exists?(:legate_scoped_states)
88
+
89
+ connection.create_table :legate_scoped_states do |t|
90
+ t.string :scope, null: false
91
+ t.string :state_key, null: false
92
+ t.text :value
93
+ t.timestamps
94
+ end
95
+ connection.add_index :legate_scoped_states, %i[scope state_key], unique: true,
96
+ name: 'index_legate_scoped_states_on_scope_and_key'
97
+ end
98
+
99
+ # Cap on cached Session objects. The cache gives a run a stable Session
100
+ # identity and avoids re-hydrating on every get_session; because every
101
+ # mutation is written through, an evicted session re-hydrates correctly
102
+ # from the database. LRU eviction only ever reclaims idle sessions — an
103
+ # active run keeps touching (and so keeps) its own session.
104
+ DEFAULT_MAX_CACHED_SESSIONS = 1_000
105
+
106
+ # @param max_cached_sessions [Integer] LRU bound for the in-process cache
107
+ def initialize(max_cached_sessions: DEFAULT_MAX_CACHED_SESSIONS)
108
+ super()
109
+ @cache = {} # insertion order = LRU order; guarded by @cache_mutex
110
+ @cache_mutex = Mutex.new
111
+ @max_cached_sessions = max_cached_sessions
112
+ Legate.logger.info('ActiveRecord session service initialized.')
113
+ end
114
+
115
+ def persistent?
116
+ true
117
+ end
118
+
119
+ def create_session(app_name:, user_id:, session_id: nil, initial_state: {})
120
+ session = Legate::Session.new(
121
+ app_name: app_name, user_id: user_id, id: session_id,
122
+ initial_state: symbolize(initial_state), session_service: self
123
+ )
124
+ SessionRecord.create!(
125
+ id: session.id, app_name: app_name, user_id: user_id, state: session.state_to_h
126
+ )
127
+ cache_put(session.id, session)
128
+ Legate.logger.info("Created persistent session: #{session.id} for app:#{app_name}, user:#{user_id}")
129
+ session
130
+ end
131
+
132
+ def get_session(session_id:)
133
+ cached = cache_get(session_id)
134
+ return cached if cached
135
+
136
+ record = SessionRecord.find_by(id: session_id)
137
+ unless record
138
+ Legate.logger.warn("Session not found: #{session_id}")
139
+ return nil
140
+ end
141
+
142
+ session = hydrate(record)
143
+ cache_put(session_id, session)
144
+ session
145
+ end
146
+
147
+ def append_event(session_id:, event:)
148
+ session = get_session(session_id: session_id)
149
+ return false unless session
150
+
151
+ session.add_event(event) # merges state_delta into the cached Session
152
+ # The event row and the merged session state must land together, or
153
+ # neither — otherwise a crash between them leaves history and state
154
+ # inconsistent.
155
+ SessionRecord.transaction do
156
+ insert_event(session_id, event)
157
+ write_session_state(session_id, session)
158
+ end
159
+ broadcast_event(session_id, event) # notify any streaming subscribers (R3)
160
+ true
161
+ rescue ::ActiveRecord::ActiveRecordError => e
162
+ Legate.logger.error("ActiveRecord session service: append_event failed for '#{session_id}': #{e.message}")
163
+ # The write rolled back but the cached Session already holds the event;
164
+ # drop it so the next get_session re-hydrates the committed truth.
165
+ cache_evict(session_id)
166
+ false
167
+ end
168
+
169
+ def delete_session(session_id:)
170
+ cache_evict(session_id)
171
+ EventRecord.where(legate_session_id: session_id).delete_all
172
+ deleted = SessionRecord.where(id: session_id).delete_all
173
+ if deleted.positive?
174
+ Legate.logger.info("Deleted persistent session: #{session_id}")
175
+ true
176
+ else
177
+ Legate.logger.warn("Attempted to delete non-existent session: #{session_id}")
178
+ false
179
+ end
180
+ end
181
+
182
+ def list_sessions(app_name: nil, user_id: nil)
183
+ scope = SessionRecord.all
184
+ scope = scope.where(app_name: app_name) if app_name
185
+ scope = scope.where(user_id: user_id) if user_id
186
+ scope.pluck(:id).filter_map { |id| get_session(session_id: id) }
187
+ end
188
+
189
+ def save_scoped_state(scope, key, value)
190
+ record = ScopedStateRecord.find_or_initialize_by(scope: scope.to_s, state_key: key.to_s)
191
+ record.value = value
192
+ record.save!
193
+ end
194
+
195
+ def load_scoped_state(scope, key)
196
+ ScopedStateRecord.find_by(scope: scope.to_s, state_key: key.to_s)&.value
197
+ end
198
+
199
+ def clear_scoped_state(scope, key)
200
+ relation = ScopedStateRecord.where(scope: scope.to_s)
201
+ relation = relation.where(state_key: key.to_s) unless key == '*'
202
+ relation.delete_all
203
+ end
204
+
205
+ def set_state(session_id:, key:, value:)
206
+ session = get_session(session_id: session_id)
207
+ unless session
208
+ Legate.logger.warn("ActiveRecord session service: Session not found '#{session_id}' when setting state for '#{key}'.")
209
+ return nil
210
+ end
211
+
212
+ session.set_state(key, value)
213
+ # Scoped keys are already persisted via save_scoped_state; write through
214
+ # the plain-key state so it survives a restart.
215
+ write_session_state(session_id, session)
216
+ nil
217
+ rescue Legate::SerializationError => e
218
+ Legate.logger.error("ActiveRecord session service: Error setting state for '#{session_id}', key '#{key}': #{e.message}")
219
+ nil
220
+ end
221
+
222
+ def get_state(session_id:, key:)
223
+ session = get_session(session_id: session_id)
224
+ return session.get_state(key) if session
225
+
226
+ Legate.logger.warn("ActiveRecord session service: Session not found '#{session_id}' when getting state for '#{key}'.")
227
+ nil
228
+ end
229
+
230
+ private
231
+
232
+ # --- Bounded LRU session cache (guarded by @cache_mutex) ---
233
+
234
+ # Return the cached session and mark it most-recently-used.
235
+ def cache_get(session_id)
236
+ @cache_mutex.synchronize do
237
+ session = @cache.delete(session_id)
238
+ @cache[session_id] = session if session # reinsert at the tail (MRU)
239
+ session
240
+ end
241
+ end
242
+
243
+ # Cache the session as most-recently-used, evicting the LRU entry if over cap.
244
+ def cache_put(session_id, session)
245
+ @cache_mutex.synchronize do
246
+ @cache.delete(session_id)
247
+ @cache[session_id] = session
248
+ @cache.delete(@cache.keys.first) if @cache.size > @max_cached_sessions
249
+ end
250
+ end
251
+
252
+ def cache_evict(session_id)
253
+ @cache_mutex.synchronize { @cache.delete(session_id) }
254
+ end
255
+
256
+ def insert_event(session_id, event)
257
+ next_position = (EventRecord.where(legate_session_id: session_id).maximum(:position) || 0) + 1
258
+ EventRecord.create!(
259
+ legate_session_id: session_id, position: next_position,
260
+ role: event.role&.to_s, content: event.content, tool_name: event.tool_name&.to_s,
261
+ state_delta: event.state_delta, event_timestamp: event.timestamp&.iso8601(3), event_id: event.event_id
262
+ )
263
+ end
264
+
265
+ # Write-through the session's plain-key state (scoped state is persisted
266
+ # separately via save_scoped_state). Shared by append_event and set_state.
267
+ def write_session_state(session_id, session)
268
+ SessionRecord.find_by(id: session_id)&.update!(state: session.state_to_h, updated_at: session.updated_at)
269
+ end
270
+
271
+ def hydrate(record)
272
+ events = record.event_records.map { |er| hydrate_event(er) }.compact
273
+ session = Legate::Session.new(
274
+ id: record.id, app_name: record.app_name, user_id: record.user_id,
275
+ initial_state: record.state || {}, events: events, session_service: self
276
+ )
277
+ session.instance_variable_set(:@created_at, record.created_at) if record.created_at
278
+ session.updated_at = record.updated_at if record.updated_at
279
+ session
280
+ end
281
+
282
+ def hydrate_event(record)
283
+ Legate::Event.from_h(
284
+ role: record.role, content: record.content, timestamp: record.event_timestamp,
285
+ tool_name: record.tool_name, state_delta: record.state_delta, event_id: record.event_id
286
+ )
287
+ end
288
+
289
+ def symbolize(hash)
290
+ return {} unless hash.is_a?(Hash)
291
+
292
+ hash.transform_keys do |k|
293
+ k.to_sym
294
+ rescue StandardError
295
+ k
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end