insika 0.2.0 → 0.7.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 (300) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +325 -17
  3. data/README.md +52 -11
  4. data/bin/insika +895 -8
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +229 -163
  7. data/docs/API.md +56 -0
  8. data/docs/ARCHITECTURE.md +3 -2
  9. data/docs/ARTIFACTS.md +95 -0
  10. data/docs/BENCHMARK.md +2 -2
  11. data/docs/CHANNELS.md +111 -21
  12. data/docs/CONTEXT.md +132 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +75 -4
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +146 -2
  17. data/docs/FACTS.md +135 -0
  18. data/docs/HARVEST.md +116 -0
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +16 -10
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +65 -6
  23. data/docs/OUTCOMES.md +137 -0
  24. data/docs/PLUGINS.md +51 -6
  25. data/docs/POLICY.md +216 -0
  26. data/docs/REFINEMENT.md +17 -12
  27. data/docs/RELEASING.md +27 -0
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +6 -6
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +79 -2
  32. data/docs/SKILLS.md +13 -4
  33. data/docs/SOAK.md +127 -0
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +220 -27
  36. data/docs/WHY.md +1 -1
  37. data/docs/WORKFLOWS.md +2 -2
  38. data/docs/_includes/head_custom.html +5 -0
  39. data/docs/_includes/title.html +13 -0
  40. data/docs/_sass/color_schemes/insika.scss +32 -0
  41. data/docs/_sass/custom/custom.scss +199 -0
  42. data/docs/_sass/custom/setup.scss +26 -0
  43. data/docs/assets/img/favicon.svg +7 -0
  44. data/docs/assets/img/insika-mark.svg +7 -0
  45. data/docs/core-concepts.md +21 -0
  46. data/docs/domain.md +115 -0
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +9 -6
  49. data/docs/integrate.md +20 -0
  50. data/docs/operate.md +13 -6
  51. data/docs/prompts/ADD-TOOL.md +118 -0
  52. data/docs/prompts/DIAGNOSE-TURN.md +65 -0
  53. data/docs/prompts/GO-LIVE.md +138 -0
  54. data/docs/prompts/RUN-EXAMPLES.md +70 -0
  55. data/docs/reference.md +19 -0
  56. data/docs/ship.md +10 -2
  57. data/docs/start-here.md +18 -0
  58. data/lib/insika/agent_profile.rb +202 -10
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/balloon_splitter.rb +102 -0
  62. data/lib/insika/budget_ledger.rb +34 -3
  63. data/lib/insika/cache_series_store.rb +49 -0
  64. data/lib/insika/channel_delivery.rb +119 -14
  65. data/lib/insika/channels/relay.rb +77 -3
  66. data/lib/insika/channels/web.rb +2 -2
  67. data/lib/insika/chat_builder.rb +105 -2
  68. data/lib/insika/checkpoint_store.rb +16 -0
  69. data/lib/insika/commands/agent_payload.rb +3 -3
  70. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  71. data/lib/insika/commands/cancel_followup.rb +49 -0
  72. data/lib/insika/commands/delete_artifact.rb +35 -0
  73. data/lib/insika/commands/delete_concept.rb +34 -0
  74. data/lib/insika/commands/delete_mcp.rb +6 -2
  75. data/lib/insika/commands/delete_tenant_data.rb +107 -0
  76. data/lib/insika/commands/export_customer_memory.rb +48 -0
  77. data/lib/insika/commands/forget_customer.rb +117 -0
  78. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  79. data/lib/insika/commands/gate_harvest.rb +138 -0
  80. data/lib/insika/commands/gate_refinement.rb +1 -1
  81. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  82. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  83. data/lib/insika/commands/memory_put_fact.rb +23 -4
  84. data/lib/insika/commands/promote_harvest.rb +130 -0
  85. data/lib/insika/commands/record_outcome.rb +46 -0
  86. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  87. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  88. data/lib/insika/commands/reject_harvest.rb +38 -0
  89. data/lib/insika/commands/resolve_proposal.rb +108 -0
  90. data/lib/insika/commands/restore_concept.rb +34 -0
  91. data/lib/insika/commands/revoke_contact.rb +49 -0
  92. data/lib/insika/commands/rollback_harvest.rb +86 -0
  93. data/lib/insika/commands/run_distillation.rb +186 -0
  94. data/lib/insika/commands/run_harvest.rb +393 -0
  95. data/lib/insika/commands/seed_demo_data.rb +31 -0
  96. data/lib/insika/commands/send_message.rb +103 -6
  97. data/lib/insika/commands/session_purge.rb +67 -0
  98. data/lib/insika/commands/upsert_mcp.rb +6 -3
  99. data/lib/insika/commands/write_concept.rb +57 -0
  100. data/lib/insika/contact_store.rb +183 -0
  101. data/lib/insika/context/builder.rb +21 -3
  102. data/lib/insika/context/fragment.rb +7 -3
  103. data/lib/insika/context/priority.rb +5 -0
  104. data/lib/insika/context/provider.rb +17 -3
  105. data/lib/insika/context/providers/briefing.rb +96 -0
  106. data/lib/insika/context/providers/knowledge.rb +108 -0
  107. data/lib/insika/context/providers/memory.rb +16 -7
  108. data/lib/insika/context/providers/prompt.rb +57 -23
  109. data/lib/insika/context/providers/skill.rb +2 -0
  110. data/lib/insika/context/providers/tool_search.rb +2 -0
  111. data/lib/insika/context_trace_store.rb +38 -2
  112. data/lib/insika/cron.rb +189 -0
  113. data/lib/insika/demo/agent_attrs.rb +43 -0
  114. data/lib/insika/demo/golden_cases.rb +81 -0
  115. data/lib/insika/demo/seeder.rb +336 -0
  116. data/lib/insika/distill.rb +224 -0
  117. data/lib/insika/distill_engine.rb +169 -0
  118. data/lib/insika/doctor.rb +889 -11
  119. data/lib/insika/dsl/definition.rb +3 -2
  120. data/lib/insika/dsl/runtime.rb +69 -82
  121. data/lib/insika/dsl/server_boot.rb +92 -1
  122. data/lib/insika/dsl/system.rb +10 -2
  123. data/lib/insika/dsl.rb +186 -5
  124. data/lib/insika/edge_limiter.rb +28 -7
  125. data/lib/insika/env_schema.rb +25 -3
  126. data/lib/insika/errors.rb +11 -0
  127. data/lib/insika/evals/assertions.rb +3 -2
  128. data/lib/insika/evals/golden.rb +41 -4
  129. data/lib/insika/evals/judge.rb +47 -2
  130. data/lib/insika/evals/pairwise.rb +29 -6
  131. data/lib/insika/evals/persona.rb +98 -0
  132. data/lib/insika/evals/runner.rb +9 -0
  133. data/lib/insika/evals/simulator.rb +225 -0
  134. data/lib/insika/evals/transport.rb +83 -1
  135. data/lib/insika/event_stream.rb +10 -0
  136. data/lib/insika/evidence.rb +183 -0
  137. data/lib/insika/executor.rb +869 -76
  138. data/lib/insika/followup_engine.rb +207 -0
  139. data/lib/insika/followup_policy.rb +198 -0
  140. data/lib/insika/followup_store.rb +306 -0
  141. data/lib/insika/funnel_declaration.rb +106 -0
  142. data/lib/insika/funnel_fold.rb +179 -0
  143. data/lib/insika/funnel_store.rb +163 -0
  144. data/lib/insika/golden_store.rb +17 -2
  145. data/lib/insika/grounding/matcher.rb +69 -0
  146. data/lib/insika/grounding.rb +44 -0
  147. data/lib/insika/harvest/conversion_gate.rb +159 -0
  148. data/lib/insika/harvest/criterion.rb +98 -0
  149. data/lib/insika/harvest/gate.rb +194 -0
  150. data/lib/insika/harvest/negative_list.rb +199 -0
  151. data/lib/insika/harvest.rb +241 -0
  152. data/lib/insika/harvest_engine.rb +193 -0
  153. data/lib/insika/harvest_store.rb +548 -0
  154. data/lib/insika/knowledge.rb +680 -0
  155. data/lib/insika/knowledge_store.rb +140 -0
  156. data/lib/insika/mcp_client.rb +94 -0
  157. data/lib/insika/mcp_json.rb +74 -0
  158. data/lib/insika/mcp_live_tool.rb +43 -0
  159. data/lib/insika/mcp_store.rb +98 -26
  160. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  161. data/lib/insika/mcp_tool_registry.rb +100 -0
  162. data/lib/insika/media.rb +382 -0
  163. data/lib/insika/memory_audit_store.rb +85 -0
  164. data/lib/insika/memory_store.rb +264 -23
  165. data/lib/insika/message_origin.rb +7 -2
  166. data/lib/insika/middleware.rb +9 -0
  167. data/lib/insika/model_visible.rb +87 -0
  168. data/lib/insika/model_visible_trace_store.rb +66 -0
  169. data/lib/insika/onboarding.rb +22 -1
  170. data/lib/insika/outbox_store.rb +42 -4
  171. data/lib/insika/outcome_store.rb +147 -0
  172. data/lib/insika/overlay_tool_registry.rb +37 -17
  173. data/lib/insika/packaging.rb +163 -0
  174. data/lib/insika/parity/criterion.rb +79 -0
  175. data/lib/insika/parity/verdict.rb +318 -0
  176. data/lib/insika/prefix_fingerprint.rb +58 -0
  177. data/lib/insika/profile_source.rb +29 -1
  178. data/lib/insika/prompt_catalog.rb +10 -0
  179. data/lib/insika/proposal_store.rb +271 -0
  180. data/lib/insika/queue_policy.rb +4 -1
  181. data/lib/insika/refinement/proposer.rb +1 -1
  182. data/lib/insika/reliability.rb +32 -6
  183. data/lib/insika/retention.rb +316 -0
  184. data/lib/insika/router/app.rb +157 -0
  185. data/lib/insika/router/backend_pool.rb +98 -0
  186. data/lib/insika/router/hash_ring.rb +55 -0
  187. data/lib/insika/router/proxy_body.rb +34 -0
  188. data/lib/insika/router/session_key.rb +54 -0
  189. data/lib/insika/router.rb +18 -0
  190. data/lib/insika/routing.rb +101 -0
  191. data/lib/insika/safety/config.rb +44 -4
  192. data/lib/insika/safety/corpus.rb +255 -0
  193. data/lib/insika/safety/detectors.rb +32 -113
  194. data/lib/insika/safety/factory.rb +15 -2
  195. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  196. data/lib/insika/safety/grounding_validator.rb +49 -0
  197. data/lib/insika/safety/input_guardrail.rb +1 -1
  198. data/lib/insika/safety/moderator.rb +1 -1
  199. data/lib/insika/safety/output_filter.rb +9 -5
  200. data/lib/insika/safety/output_validator.rb +11 -5
  201. data/lib/insika/schedule.rb +177 -0
  202. data/lib/insika/schedule_engine.rb +314 -0
  203. data/lib/insika/schedule_store.rb +208 -0
  204. data/lib/insika/schema_guard.rb +35 -0
  205. data/lib/insika/server/app.rb +247 -17
  206. data/lib/insika/server/rack_app.rb +21 -1
  207. data/lib/insika/server/responses.rb +40 -1
  208. data/lib/insika/session_actor.rb +8 -4
  209. data/lib/insika/session_store.rb +65 -2
  210. data/lib/insika/settings_store.rb +10 -0
  211. data/lib/insika/shadow_pair_store.rb +258 -0
  212. data/lib/insika/skill_catalog.rb +16 -0
  213. data/lib/insika/soak/envelope.rb +140 -0
  214. data/lib/insika/soak/report.rb +392 -0
  215. data/lib/insika/soak/runner.rb +554 -0
  216. data/lib/insika/steer_injector.rb +21 -10
  217. data/lib/insika/store.rb +10 -1
  218. data/lib/insika/stores/memory.rb +6 -0
  219. data/lib/insika/stores/sqlite.rb +8 -0
  220. data/lib/insika/studio/app.rb +1437 -54
  221. data/lib/insika/studio/assets/dist/application.css +1 -1
  222. data/lib/insika/studio/assets/dist/application.js +23 -23
  223. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  224. data/lib/insika/studio/forms.rb +308 -10
  225. data/lib/insika/studio/nav_icons.rb +19 -1
  226. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  227. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  228. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  229. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  230. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  231. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  232. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  233. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  234. data/lib/insika/studio/views/_agents_master.erb +44 -0
  235. data/lib/insika/studio/views/_message.erb +49 -32
  236. data/lib/insika/studio/views/agent_detail.erb +62 -278
  237. data/lib/insika/studio/views/agents.erb +71 -54
  238. data/lib/insika/studio/views/approvals.erb +4 -1
  239. data/lib/insika/studio/views/artifact.erb +23 -0
  240. data/lib/insika/studio/views/artifacts.erb +59 -0
  241. data/lib/insika/studio/views/chats.erb +4 -1
  242. data/lib/insika/studio/views/customer.erb +94 -0
  243. data/lib/insika/studio/views/customers.erb +32 -0
  244. data/lib/insika/studio/views/evals.erb +6 -3
  245. data/lib/insika/studio/views/facts.erb +133 -0
  246. data/lib/insika/studio/views/followups.erb +125 -0
  247. data/lib/insika/studio/views/funnel.erb +106 -0
  248. data/lib/insika/studio/views/harvest.erb +234 -0
  249. data/lib/insika/studio/views/home.erb +106 -66
  250. data/lib/insika/studio/views/knowledge.erb +123 -0
  251. data/lib/insika/studio/views/layout.erb +15 -11
  252. data/lib/insika/studio/views/mcp.erb +174 -80
  253. data/lib/insika/studio/views/parity.erb +147 -0
  254. data/lib/insika/studio/views/playground.erb +7 -1
  255. data/lib/insika/studio/views/session.erb +233 -124
  256. data/lib/insika/studio/views/settings.erb +41 -1
  257. data/lib/insika/studio/views/skills.erb +1 -2
  258. data/lib/insika/studio/views/system_files.erb +1 -1
  259. data/lib/insika/studio/views/task.erb +13 -0
  260. data/lib/insika/studio/views/tasks.erb +4 -1
  261. data/lib/insika/studio/views/tools.erb +24 -10
  262. data/lib/insika/task_store.rb +21 -1
  263. data/lib/insika/templates/browser-agent/README.md +36 -0
  264. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  265. data/lib/insika/templates/daily-digest/README.md +38 -0
  266. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  267. data/lib/insika/templates/repo-explorer/README.md +36 -0
  268. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  269. data/lib/insika/templates/research-analyst/README.md +26 -0
  270. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  271. data/lib/insika/templates/review-panel/README.md +20 -0
  272. data/lib/insika/templates/review-panel/agent.rb +50 -0
  273. data/lib/insika/templates/travel-planner/README.md +35 -0
  274. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  275. data/lib/insika/templates.rb +112 -0
  276. data/lib/insika/testing/store_contract.rb +27 -6
  277. data/lib/insika/tick.rb +47 -11
  278. data/lib/insika/timezone.rb +45 -0
  279. data/lib/insika/tool_definition.rb +17 -7
  280. data/lib/insika/tool_envelope.rb +69 -0
  281. data/lib/insika/tool_manifest.rb +5 -1
  282. data/lib/insika/tools/data_defined_tool.rb +10 -0
  283. data/lib/insika/tools/generate_image.rb +89 -0
  284. data/lib/insika/tools/load_knowledge.rb +74 -0
  285. data/lib/insika/tools/run_persona_eval.rb +328 -0
  286. data/lib/insika/tools/save_artifact.rb +95 -0
  287. data/lib/insika/tools/schedule_followup.rb +164 -0
  288. data/lib/insika/tools/tts.rb +47 -0
  289. data/lib/insika/tools/update_briefing.rb +126 -0
  290. data/lib/insika/turn_output.rb +1 -1
  291. data/lib/insika/turn_state.rb +49 -1
  292. data/lib/insika/turn_timing.rb +22 -2
  293. data/lib/insika/version.rb +1 -1
  294. data/lib/insika/vitals.rb +84 -0
  295. data/lib/insika/wiring/graph.rb +472 -13
  296. data/lib/insika/wiring/graph_chat.rb +102 -0
  297. data/lib/insika.rb +149 -3
  298. metadata +181 -5
  299. data/docs/build.md +0 -14
  300. data/docs/understand.md +0 -10
@@ -27,13 +27,19 @@ module Insika
27
27
  class OutputValidator
28
28
  # `ask_factory` (optional): ->(config) { ->(prompt){text} | nil }, built by the
29
29
  # Safety::Factory from the utility_model. nil = deterministic only.
30
- def initialize(ask_factory: nil)
30
+ # `grounding` (optional): a GroundingValidator — its step runs
31
+ # FIRST in #call, before the `config.output` gate (D9: grounding is
32
+ # evidence integrity, independent of the guardrails opt-in).
33
+ def initialize(ask_factory: nil, grounding: nil)
31
34
  @ask_factory = ask_factory
35
+ @grounding = grounding
32
36
  end
33
37
 
34
38
  # after_task hook body. Idempotent and defensive: never raises out (a hook
35
- # error must not fail a committed turn).
39
+ # error must not fail a committed turn). Grounding runs BEFORE the output
40
+ # gate so an agent with guardrails off and grounding on still gets the check.
36
41
  def call(state)
42
+ state = @grounding&.call(state) || state
37
43
  config = Config.from_profile(state.profile)
38
44
  return state unless config.output
39
45
 
@@ -41,7 +47,7 @@ module Insika
41
47
  return state if text.empty?
42
48
 
43
49
  flags = []
44
- flags.concat(deterministic_flags(text))
50
+ flags.concat(deterministic_flags(text, config.corpus))
45
51
  flags.concat(llm_flags(text, config)) if config.moderator?
46
52
 
47
53
  state.guardrail_flags = Array(state.guardrail_flags) + flags unless flags.empty?
@@ -55,8 +61,8 @@ module Insika
55
61
  # Residual PII/secret that somehow reached the final text (the stream filter
56
62
  # should have caught it — this is defense in depth, and the flag itself
57
63
  # carries category counts, never the raw value).
58
- def deterministic_flags(text)
59
- _redacted, counts = Detectors.redact(text)
64
+ def deterministic_flags(text, corpus)
65
+ _redacted, counts = Detectors.redact(text, corpus: corpus)
60
66
  return [] if counts.empty?
61
67
 
62
68
  [{ category: "pii_residual", source: "deterministic", detail: counts.map { |k, v| "#{k}:#{v}" }.join(",") }]
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # the parsed recurring-schedule declaration of ONE agent —
5
+ # the ONLY shape the engine accepts, shared by the ScheduleEngine, the
6
+ # doctor and the Studio. Pure value object (the followup-policy precedent).
7
+ #
8
+ # One schedule: a name, a trigger (`cron` OR `every`, never both), a
9
+ # timezone for cron materialization, the synthetic inbound message, a
10
+ # session mode (a fresh session per run, or a standing one), per-run
11
+ # overrides (turn_timeout / max_tool_calls / model) and an enabled flag.
12
+ #
13
+ # `parse` returns nil on a malformed hash (the engine SKIPS, the doctor
14
+ # explains, the Studio refuses); `parse!` raises Insika::ValidationError
15
+ # naming the exact defect.
16
+ class Schedule
17
+ SESSION_MODES = %w[new fixed].freeze
18
+ OVERRIDE_KEYS = %w[turn_timeout max_tool_calls model].freeze
19
+ ID_RE = /\A[a-z][a-z0-9_-]*\z/
20
+
21
+ attr_reader :id, :every, :cron, :tz, :message, :session_mode, :session_id,
22
+ :overrides, :enabled
23
+
24
+ def self.parse(hash)
25
+ new(hash)
26
+ rescue Insika::ValidationError
27
+ nil
28
+ end
29
+
30
+ def self.parse!(hash)
31
+ new(hash)
32
+ end
33
+
34
+ def initialize(hash)
35
+ raise Insika::ValidationError, "schedule: declaration must be a Hash" unless hash.is_a?(Hash)
36
+
37
+ h = hash.transform_keys(&:to_s)
38
+ @id = id_of(h)
39
+ @every = every_of(h)
40
+ @cron = cron_of(h)
41
+ if @cron.nil? && @every.nil?
42
+ raise Insika::ValidationError,
43
+ "schedule '#{@id}': a trigger is required — declare cron or every"
44
+ end
45
+ @tz = tz_of(h)
46
+ @message = message_of(h)
47
+ @session_mode = session_mode_of(h)
48
+ @session_id = session_id_of(h)
49
+ @overrides = overrides_of(h)
50
+ @enabled = h.key?("enabled") ? h["enabled"] == true : true
51
+ freeze
52
+ end
53
+
54
+ def cron? = !@cron.nil?
55
+ def every? = !@every.nil?
56
+ def fixed_session? = @session_mode == "fixed"
57
+
58
+ def to_h
59
+ { "id" => @id, "cron" => @cron, "every" => @every, "tz" => @tz,
60
+ "message" => @message, "session_mode" => @session_mode,
61
+ "session_id" => @session_id, "overrides" => @overrides,
62
+ "enabled" => @enabled }.compact
63
+ end
64
+
65
+ private
66
+
67
+ def id_of(h)
68
+ id = h["id"].to_s.strip.downcase
69
+ unless ID_RE.match?(id)
70
+ raise Insika::ValidationError,
71
+ "schedule.id must match #{ID_RE.inspect}, got: #{h['id'].inspect}"
72
+ end
73
+
74
+ id
75
+ end
76
+
77
+ def every_of(h)
78
+ every = h["every"]
79
+ return nil if every.nil?
80
+
81
+ unless every.is_a?(Integer) && every.positive?
82
+ raise Insika::ValidationError,
83
+ "schedule.every must be a positive integer of seconds, got: #{h['every'].inspect}"
84
+ end
85
+
86
+ every
87
+ end
88
+
89
+ def cron_of(h)
90
+ cron = h["cron"]
91
+ return nil if cron.nil?
92
+
93
+ if h.key?("every") && !h["every"].nil?
94
+ raise Insika::ValidationError,
95
+ "schedule '#{@id}': cron and every are mutually exclusive — declare exactly one trigger"
96
+ end
97
+
98
+ Insika::Cron.new(cron) # raises ValidationError on a malformed expression
99
+ cron.to_s
100
+ end
101
+
102
+ def tz_of(h)
103
+ tz = h["tz"].to_s
104
+ tz = "Etc/UTC" if tz.empty?
105
+ unless Insika::Timezone.known?(tz)
106
+ raise Insika::ValidationError, "schedule '#{@id}'.tz is not a valid IANA timezone: #{tz.inspect}"
107
+ end
108
+
109
+ tz
110
+ end
111
+
112
+ def message_of(h)
113
+ message = h["message"]
114
+ if Coercion.blank?(message)
115
+ raise Insika::ValidationError,
116
+ "schedule '#{@id}'.message is required — the synthetic inbound that kicks each run"
117
+ end
118
+
119
+ message.to_s
120
+ end
121
+
122
+ def session_mode_of(h)
123
+ mode = h["session_mode"]
124
+ return "new" if mode.nil?
125
+
126
+ mode = mode.to_s
127
+ unless SESSION_MODES.include?(mode)
128
+ raise Insika::ValidationError,
129
+ "schedule '#{@id}'.session_mode must be one of #{SESSION_MODES.inspect}, got: #{h['session_mode'].inspect}"
130
+ end
131
+
132
+ mode
133
+ end
134
+
135
+ def session_id_of(h)
136
+ session_id = h["session_id"]
137
+ return nil if session_id.nil?
138
+
139
+ session_id.to_s
140
+ end
141
+
142
+ def overrides_of(h)
143
+ overrides = h["overrides"]
144
+ return nil if overrides.nil?
145
+
146
+ raise Insika::ValidationError, "schedule '#{@id}'.overrides must be a Hash" unless overrides.is_a?(Hash)
147
+
148
+ overrides = overrides.transform_keys(&:to_s)
149
+ unknown = overrides.keys - OVERRIDE_KEYS
150
+ unless unknown.empty?
151
+ raise Insika::ValidationError,
152
+ "schedule '#{@id}'.overrides: unknown key(s) #{unknown.inspect} " \
153
+ "(allowed: #{OVERRIDE_KEYS.join(', ')})"
154
+ end
155
+
156
+ overrides.each do |key, value|
157
+ # model is a provider/model REF — validated at declaration, not left
158
+ # for model-resolution time; everything else is an integer ceiling.
159
+ if key == "model"
160
+ if !value.is_a?(String) || value.strip.empty?
161
+ raise Insika::ValidationError,
162
+ "schedule '#{@id}'.overrides.model must be a non-blank String (a " \
163
+ "model ref), got: #{value.inspect}"
164
+ end
165
+ next
166
+ end
167
+
168
+ unless value.is_a?(Integer) && value.positive?
169
+ raise Insika::ValidationError,
170
+ "schedule '#{@id}'.overrides.#{key} must be a positive Integer, got: #{value.inspect}"
171
+ end
172
+ end
173
+
174
+ overrides
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ # the tick-driven FIRER of recurring schedules: the engine's
7
+ # fourth duty (after the outbox drain, retention/funnel and the follow-up
8
+ # firer). One pass per claim window (the followup/funnel idiom); each due
9
+ # schedule is claimed transactionally — re-read inside its own transaction,
10
+ # so two workers racing a window serialize on the backend lock and exactly
11
+ # one fires (the multi-worker at-most-once claim, per row).
12
+ #
13
+ # Gating order:
14
+ #
15
+ # declared? (reconciled from the profiles) -> enabled + due -> no-catch-up
16
+ # (a window older than one claim window is MISSED, recorded, never
17
+ # replayed) -> overlap (the last task still live) -> budget (a hard
18
+ # window at/over cap) -> FIRE.
19
+ #
20
+ # Skips are DATA, never silent: `last_skip { at, reason }` on the row, for
21
+ # the Studio. The engine never queues — a skipped window advances the
22
+ # schedule's lattice. The turn it creates is delivered by the existing
23
+ # pipeline (it holds no channel code, like the FollowupEngine).
24
+ class ScheduleEngine
25
+ SCOPE = "schedule_fire"
26
+ KEY = "claim"
27
+ DEFAULT_WINDOW = 300 # seconds; one firing worker per window
28
+ TENANT = "platform" # the single-tenant default (ledger rules) — see `tenant_for`
29
+
30
+ ACTIVE_STATUSES = %i[queued running waiting paused].freeze
31
+
32
+ # The tenant a SCHEDULED turn declares. Every other turn gets its tenant
33
+ # from its CALLER (an authenticated tenant token, a Command built with
34
+ # `tenant:`) — a scheduled turn has no caller, so it is the agent's OWN
35
+ # declaration instead: `metadata["tenant"]`, the same "stable per agent,
36
+ # from the pack" home `store_id` already lives in (never the model, never
37
+ # a policy — just a fact the pack states). Absent -> the single-tenant
38
+ # default, unchanged for every profile that does not declare one.
39
+ #
40
+ # PUBLIC and STATELESS on purpose: the Studio's own schedule list
41
+ # (`studio/app.rb`) must resolve to the exact SAME tenant the fire path
42
+ # uses, or a declared schedule becomes invisible there — one formula, two
43
+ # callers, never a second copy to drift.
44
+ def self.tenant_for(profile)
45
+ meta = profile.respond_to?(:metadata) ? profile.metadata : nil
46
+ Insika::Coercion.presence(meta && meta["tenant"]) || TENANT
47
+ end
48
+
49
+ def initialize(store:, schedule_store:, task_store:, session_store:,
50
+ profiles:, executor:, budget_ledger: nil, event_stream: nil,
51
+ window: DEFAULT_WINDOW, now: nil)
52
+ @store = store
53
+ @schedule_store = schedule_store
54
+ @task_store = task_store
55
+ @session_store = session_store
56
+ @profiles = profiles
57
+ @executor = executor
58
+ @budget_ledger = budget_ledger
59
+ @event_stream = event_stream
60
+ @window = window
61
+ @now = now
62
+ end
63
+
64
+ # -> { claimed: false }
65
+ # | { claimed: true, fired: N, skipped: N, errors: N,
66
+ # skip_reasons: { "reason" => N } }
67
+ # A StoreError on ONE schedule aborts THAT schedule's transaction
68
+ # (rescued, counted, the loop continues) — a broken row must not hold the
69
+ # other schedules' runs hostage.
70
+ def run
71
+ now_time = @now || Time.now.utc
72
+ return { claimed: false } unless claim_window(now_time)
73
+
74
+ sync_from_profiles(now_time)
75
+
76
+ fired = 0
77
+ skipped = 0
78
+ errors = 0
79
+ reasons = Hash.new(0)
80
+
81
+ @schedule_store.due(now: now_time).each do |record|
82
+ begin
83
+ outcome = fire_record(record, now_time)
84
+ case outcome
85
+ when :fired then fired += 1
86
+ when Array
87
+ # a skip is recorded on the row (never silent) and counted here.
88
+ skipped += 1
89
+ reasons[outcome[1].to_s] += 1
90
+ end
91
+ rescue StandardError
92
+ # a broken schedule must not hold the other schedules' runs
93
+ # hostage — its own transaction already rolled back.
94
+ errors += 1
95
+ end
96
+ end
97
+
98
+ { claimed: true, fired: fired, skipped: skipped, errors: errors,
99
+ skip_reasons: reasons }
100
+ end
101
+
102
+ private
103
+
104
+ # Reconciliation: the PROFILES are the source of the declarations (DSL /
105
+ # API / Studio); the store rows are the derived view the fire path reads.
106
+ # One pass: drop rows whose agent no longer exists, then upsert each
107
+ # profile's declared schedules (and drop each agent's undeclared rows).
108
+ def sync_from_profiles(now_time)
109
+ ids = @profiles.ids.map(&:to_s)
110
+ @store.transaction do
111
+ @schedule_store.all.each do |row|
112
+ @schedule_store.delete(tenant: row.tenant, agent: row.agent, id: row.id) unless ids.include?(row.agent)
113
+ end
114
+ @profiles.all.each do |profile|
115
+ schedules = profile.respond_to?(:schedules) ? profile.schedules : nil
116
+ @schedule_store.sync_declared(tenant: self.class.tenant_for(profile), agent: profile.id,
117
+ schedules: schedules, now: now_time)
118
+ end
119
+ end
120
+ end
121
+
122
+ # -> :fired | [:skipped, reason] — claimed per row, inside ONE
123
+ # transaction: the re-read, the gates, the task creation and the lattice
124
+ # advance commit together or not at all — and the SPAWN happens AFTER the
125
+ # commit, so a spawn failure never unwinds the fire (the durable :queued
126
+ # task is the recovery sweep's to handle). `next`, never `return`, inside
127
+ # the block (a non-local return skips the backend's COMMIT).
128
+ def fire_record(record, now_time)
129
+ task = nil
130
+ outcome = nil
131
+ spawn_profile = nil
132
+ @store.transaction do
133
+ current = @schedule_store.find(tenant: record.tenant, agent: record.agent, id: record.id)
134
+ # a racing worker already advanced/deleted the row — nothing for this pass.
135
+ unless current
136
+ outcome = [:skipped, :stale]
137
+ next
138
+ end
139
+ unless current.enabled
140
+ outcome = [:skipped, :stale]
141
+ next
142
+ end
143
+ next_at = Time.iso8601(current.next_fire_at.to_s)
144
+ unless next_at <= now_time
145
+ outcome = [:skipped, :stale] # the racing worker claimed the window
146
+ next
147
+ end
148
+
149
+ # the no-catch-up policy: a window older than ONE claim window is
150
+ # MISSED, not replayed — the lattice advances and the row records it.
151
+ if next_at < now_time - @window
152
+ @schedule_store.mark_skip(id: current.id, tenant: current.tenant,
153
+ agent: current.agent, reason: :late,
154
+ next_fire_at: next_after(current, now_time),
155
+ now: now_time)
156
+ outcome = [:skipped, :late]
157
+ next
158
+ end
159
+
160
+ # overlap: the previous run is still live — skip + record, never a queue.
161
+ if overlap?(current)
162
+ @schedule_store.mark_skip(id: current.id, tenant: current.tenant,
163
+ agent: current.agent, reason: :overlap,
164
+ next_fire_at: next_after(current, now_time),
165
+ now: now_time)
166
+ outcome = [:skipped, :overlap]
167
+ next
168
+ end
169
+
170
+ profile = @profiles.fetch(current.agent)
171
+ if profile && budget_exhausted?(profile, current, now_time)
172
+ @schedule_store.mark_skip(id: current.id, tenant: current.tenant,
173
+ agent: current.agent, reason: :budget,
174
+ next_fire_at: next_after(current, now_time),
175
+ now: now_time)
176
+ outcome = [:skipped, :budget]
177
+ next
178
+ end
179
+
180
+ task = commit_run(current, profile, now_time)
181
+ spawn_profile = derived_profile(current, profile)
182
+ outcome = :fired
183
+ end
184
+ return outcome || [:skipped, :stale] unless task
185
+
186
+ # AFTER the commit: the spawn. A failure propagates to the pass (counted
187
+ # as an error) — the fire already committed, and the :queued task is
188
+ # recovered by the tick's sweep.
189
+ @executor.spawn_in_session(task, profile: spawn_profile)
190
+ emit_fired(record, task.id)
191
+ :fired
192
+ end
193
+
194
+ # The atomic claim inside the pass's transaction: the task and the
195
+ # schedule's state (last run, task id, next fire) commit together or not
196
+ # at all (the follow-up firer's D5 shape).
197
+ def commit_run(current, profile, now_time)
198
+ session_id = resolve_session(current)
199
+ command = {
200
+ "type" => "scheduled_run",
201
+ "session_id" => session_id,
202
+ "payload" => {
203
+ "agent" => current.agent, "session_id" => session_id,
204
+ "message" => current.message, "origin" => Insika::MessageOrigin::SCHEDULED,
205
+ "schedule_id" => current.id
206
+ },
207
+ "meta" => { "tenant" => current.tenant, "transport" => "schedule" }
208
+ }
209
+ task = @task_store.create(command: command, session_id: session_id)
210
+ @schedule_store.transition_fire(id: current.id, tenant: current.tenant,
211
+ agent: current.agent, task_id: task.id,
212
+ next_fire_at: next_after(current, now_time),
213
+ now: now_time)
214
+ task
215
+ end
216
+
217
+ # -> session_id for the run. session_mode "new" = a fresh session per run
218
+ # (the report case); "fixed" = the declared session, created on first run
219
+ # (the "standing assistant" case).
220
+ def resolve_session(current)
221
+ if current.session_mode == "fixed"
222
+ sid = current.session_id.to_s
223
+ sid = "sched-#{current.agent}-#{current.id}" if sid.empty?
224
+ @session_store.create(id: sid) unless @session_store.find(sid)
225
+ sid
226
+ else
227
+ @session_store.create.id
228
+ end
229
+ end
230
+
231
+ # The profile the turn runs on: the base profile with the schedule's
232
+ # overrides merged (per-schedule ceiling, never a store-wide change). The
233
+ # base is untouched — a second schedule cannot see a sibling's overrides.
234
+ def derived_profile(current, base)
235
+ overrides = current.overrides
236
+ return base if overrides.nil? || overrides.empty?
237
+
238
+ limits = base.limits.dup
239
+ limits[:turn_timeout] = overrides["turn_timeout"] if overrides["turn_timeout"]
240
+ limits[:max_tool_calls] = overrides["max_tool_calls"] if overrides["max_tool_calls"]
241
+ Insika::AgentProfile.build(**base.to_h.merge(limits: limits,
242
+ model: overrides["model"] || base.model))
243
+ end
244
+
245
+ # The next lattice point after `now`: for `every`, the next interval
246
+ # boundary; for cron, the next expression occurrence in the schedule's tz.
247
+ # nil (a cron that can never fire) makes the row never due again.
248
+ def next_after(current, now_time)
249
+ if current.every
250
+ base = Time.iso8601(current.next_fire_at.to_s)
251
+ base + ((now_time - base).to_i / current.every + 1) * current.every
252
+ else
253
+ Insika::Cron.new(current.cron).next_after(now_time, tz: current.tz)
254
+ end
255
+ end
256
+
257
+ def overlap?(current)
258
+ task_id = current.last_task_id.to_s
259
+ return false if task_id.empty?
260
+
261
+ task = @task_store.find(task_id)
262
+ task && ACTIVE_STATUSES.include?(task.status)
263
+ end
264
+
265
+ # A HARD budget at/over a window cap = skip (the edge would fail the turn
266
+ # anyway — this refuses to even queue it). A soft budget crosses and runs;
267
+ # the ledger warns as usual. Mirror of EdgeLimiter#budget_windows — the
268
+ # SOFT half is the edge's, this is the schedule gate's.
269
+ def budget_exhausted?(profile, current, now_time)
270
+ budget = profile.respond_to?(:budget) ? profile.budget : nil
271
+ return false if budget.nil? || @budget_ledger.nil?
272
+
273
+ soft = budget["soft"] == true
274
+ %i[daily monthly].any? do |window|
275
+ cap = budget[window.to_s].to_i
276
+ next false unless cap.positive?
277
+
278
+ spent = @budget_ledger.current(tenant: current.tenant, agent: current.agent,
279
+ now: now_time)[window]
280
+ !soft && spent >= cap
281
+ end
282
+ end
283
+
284
+ def emit_fired(record, task_id)
285
+ return unless @event_stream
286
+
287
+ @event_stream.emit(Insika::Event.new(
288
+ type: :schedule_fired,
289
+ data: { id: record.id, agent: record.agent, task_id: task_id },
290
+ meta: { tenant: record.tenant, at: Time.now.utc.iso8601 }
291
+ ))
292
+ end
293
+
294
+ # The claim window (the funnel_fold.rb idiom — read-check-write on one key
295
+ # inside a transaction): the O(n) scans never ride the 60 s tick, and two
296
+ # workers racing a pass serialize on the backend's lock.
297
+ def claim_window(now_time)
298
+ @store.transaction do
299
+ current = @store.get(SCOPE, KEY)
300
+ last = current && begin
301
+ Time.iso8601(current["claimed_at"].to_s)
302
+ rescue ArgumentError
303
+ nil # a corrupted claim is not a claim — take the window
304
+ end
305
+ if last.nil? || (now_time - last) >= @window
306
+ @store.set(SCOPE, KEY, { "claimed_at" => now_time.iso8601 })
307
+ true
308
+ else
309
+ false
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end