insika 0.3.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 (190) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +180 -0
  3. data/README.md +45 -10
  4. data/bin/insika +684 -0
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +94 -403
  7. data/docs/API.md +5 -5
  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 +14 -14
  12. data/docs/CONTEXT.md +9 -7
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +71 -3
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +128 -3
  17. data/docs/FACTS.md +3 -3
  18. data/docs/HARVEST.md +5 -6
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +2 -2
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +15 -10
  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 +14 -9
  27. data/docs/RELEASING.md +4 -4
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +3 -3
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +22 -6
  32. data/docs/SKILLS.md +11 -2
  33. data/docs/SOAK.md +2 -2
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +152 -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 +4 -4
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +8 -5
  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 +73 -16
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/channel_delivery.rb +1 -1
  62. data/lib/insika/chat_builder.rb +22 -2
  63. data/lib/insika/commands/agent_payload.rb +2 -2
  64. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  65. data/lib/insika/commands/delete_artifact.rb +35 -0
  66. data/lib/insika/commands/delete_concept.rb +34 -0
  67. data/lib/insika/commands/delete_mcp.rb +6 -2
  68. data/lib/insika/commands/delete_tenant_data.rb +15 -3
  69. data/lib/insika/commands/gate_refinement.rb +1 -1
  70. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  71. data/lib/insika/commands/restore_concept.rb +34 -0
  72. data/lib/insika/commands/seed_demo_data.rb +31 -0
  73. data/lib/insika/commands/upsert_mcp.rb +6 -3
  74. data/lib/insika/commands/write_concept.rb +57 -0
  75. data/lib/insika/context/priority.rb +2 -0
  76. data/lib/insika/context/providers/knowledge.rb +108 -0
  77. data/lib/insika/context/providers/prompt.rb +30 -24
  78. data/lib/insika/cron.rb +189 -0
  79. data/lib/insika/demo/agent_attrs.rb +43 -0
  80. data/lib/insika/demo/golden_cases.rb +81 -0
  81. data/lib/insika/demo/seeder.rb +336 -0
  82. data/lib/insika/doctor.rb +176 -8
  83. data/lib/insika/dsl/definition.rb +3 -2
  84. data/lib/insika/dsl/runtime.rb +60 -79
  85. data/lib/insika/dsl/server_boot.rb +23 -1
  86. data/lib/insika/dsl/system.rb +10 -2
  87. data/lib/insika/dsl.rb +103 -2
  88. data/lib/insika/env_schema.rb +16 -1
  89. data/lib/insika/evals/golden.rb +41 -4
  90. data/lib/insika/evals/judge.rb +47 -2
  91. data/lib/insika/evals/pairwise.rb +11 -0
  92. data/lib/insika/evals/persona.rb +98 -0
  93. data/lib/insika/evals/runner.rb +9 -0
  94. data/lib/insika/evals/simulator.rb +225 -0
  95. data/lib/insika/evals/transport.rb +83 -1
  96. data/lib/insika/event_stream.rb +10 -0
  97. data/lib/insika/executor.rb +231 -55
  98. data/lib/insika/followup_policy.rb +2 -25
  99. data/lib/insika/golden_store.rb +16 -1
  100. data/lib/insika/grounding/matcher.rb +1 -1
  101. data/lib/insika/knowledge.rb +680 -0
  102. data/lib/insika/knowledge_store.rb +140 -0
  103. data/lib/insika/mcp_client.rb +94 -0
  104. data/lib/insika/mcp_json.rb +74 -0
  105. data/lib/insika/mcp_live_tool.rb +43 -0
  106. data/lib/insika/mcp_store.rb +98 -26
  107. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  108. data/lib/insika/mcp_tool_registry.rb +100 -0
  109. data/lib/insika/media.rb +115 -31
  110. data/lib/insika/message_origin.rb +1 -1
  111. data/lib/insika/middleware.rb +9 -0
  112. data/lib/insika/onboarding.rb +17 -1
  113. data/lib/insika/outcome_store.rb +1 -1
  114. data/lib/insika/overlay_tool_registry.rb +37 -17
  115. data/lib/insika/packaging.rb +2 -2
  116. data/lib/insika/profile_source.rb +8 -1
  117. data/lib/insika/prompt_catalog.rb +10 -0
  118. data/lib/insika/retention.rb +36 -1
  119. data/lib/insika/router/app.rb +157 -0
  120. data/lib/insika/router/backend_pool.rb +98 -0
  121. data/lib/insika/router/hash_ring.rb +55 -0
  122. data/lib/insika/router/proxy_body.rb +34 -0
  123. data/lib/insika/router/session_key.rb +54 -0
  124. data/lib/insika/router.rb +18 -0
  125. data/lib/insika/schedule.rb +177 -0
  126. data/lib/insika/schedule_engine.rb +314 -0
  127. data/lib/insika/schedule_store.rb +208 -0
  128. data/lib/insika/server/app.rb +105 -15
  129. data/lib/insika/server/rack_app.rb +5 -1
  130. data/lib/insika/server/responses.rb +1 -1
  131. data/lib/insika/skill_catalog.rb +12 -0
  132. data/lib/insika/steer_injector.rb +21 -10
  133. data/lib/insika/studio/app.rb +567 -45
  134. data/lib/insika/studio/assets/dist/application.css +1 -1
  135. data/lib/insika/studio/assets/dist/application.js +21 -21
  136. data/lib/insika/studio/forms.rb +46 -5
  137. data/lib/insika/studio/nav_icons.rb +14 -1
  138. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  139. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  140. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  141. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  142. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  143. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  144. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  145. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  146. data/lib/insika/studio/views/_agents_master.erb +44 -0
  147. data/lib/insika/studio/views/_message.erb +49 -32
  148. data/lib/insika/studio/views/agent_detail.erb +61 -820
  149. data/lib/insika/studio/views/agents.erb +70 -57
  150. data/lib/insika/studio/views/artifact.erb +23 -0
  151. data/lib/insika/studio/views/artifacts.erb +59 -0
  152. data/lib/insika/studio/views/evals.erb +2 -2
  153. data/lib/insika/studio/views/facts.erb +1 -1
  154. data/lib/insika/studio/views/funnel.erb +1 -1
  155. data/lib/insika/studio/views/home.erb +106 -67
  156. data/lib/insika/studio/views/knowledge.erb +123 -0
  157. data/lib/insika/studio/views/layout.erb +14 -11
  158. data/lib/insika/studio/views/mcp.erb +174 -80
  159. data/lib/insika/studio/views/session.erb +231 -177
  160. data/lib/insika/studio/views/settings.erb +39 -1
  161. data/lib/insika/studio/views/skills.erb +1 -1
  162. data/lib/insika/studio/views/tools.erb +24 -9
  163. data/lib/insika/templates/browser-agent/README.md +36 -0
  164. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  165. data/lib/insika/templates/daily-digest/README.md +38 -0
  166. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  167. data/lib/insika/templates/repo-explorer/README.md +36 -0
  168. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  169. data/lib/insika/templates/research-analyst/README.md +26 -0
  170. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  171. data/lib/insika/templates/review-panel/README.md +20 -0
  172. data/lib/insika/templates/review-panel/agent.rb +50 -0
  173. data/lib/insika/templates/travel-planner/README.md +35 -0
  174. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  175. data/lib/insika/templates.rb +112 -0
  176. data/lib/insika/tick.rb +24 -12
  177. data/lib/insika/timezone.rb +45 -0
  178. data/lib/insika/tools/generate_image.rb +52 -7
  179. data/lib/insika/tools/load_knowledge.rb +74 -0
  180. data/lib/insika/tools/run_persona_eval.rb +328 -0
  181. data/lib/insika/tools/save_artifact.rb +95 -0
  182. data/lib/insika/turn_output.rb +1 -1
  183. data/lib/insika/turn_state.rb +15 -4
  184. data/lib/insika/version.rb +1 -1
  185. data/lib/insika/wiring/graph.rb +184 -12
  186. data/lib/insika/wiring/graph_chat.rb +102 -0
  187. data/lib/insika.rb +57 -0
  188. metadata +105 -5
  189. data/docs/build.md +0 -14
  190. data/docs/understand.md +0 -10
@@ -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
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Insika
7
+ # the per-agent schedule rows of the recurring feature — one
8
+ # row per declared schedule, keyed `tenant:agent:id`, holding BOTH the
9
+ # declaration (a copy of the profile's `schedules` entry — the engine reads
10
+ # the store, never the profile, on the fire path) and the runtime state
11
+ # (`next_fire_at`, `last_run_at`, `last_task_id`, `last_skip`).
12
+ #
13
+ # The row is a DERIVED record: the profile declaration is the config (the
14
+ # DSL, the API, the Studio all edit the profile); the ScheduleEngine
15
+ # reconciles rows with declarations at each pass. The engine OWNS the
16
+ # runtime writes — `transition_fire`/`mark_skip` are called only inside the
17
+ # engine's transaction (the D5 discipline), never by a consumer. A row that
18
+ # is no longer declared is deleted (no zombie schedules); a row whose
19
+ # declaration is malformed is deleted too — a broken schedule must not keep
20
+ # firing with stale text (the doctor names it).
21
+ #
22
+ # Multi-worker at-most-once rides Store#transaction: the pass re-reads the
23
+ # row inside the transaction, so two workers serialize on the backend lock
24
+ # and only one advances `next_fire_at`.
25
+ class ScheduleStore
26
+ SCOPE = "schedules"
27
+
28
+ Record = Data.define(:id, :tenant, :agent, :every, :cron, :tz, :message,
29
+ :session_mode, :session_id, :overrides, :enabled,
30
+ :next_fire_at, :last_run_at, :last_task_id, :last_skip,
31
+ :created_at, :updated_at)
32
+
33
+ def initialize(store:)
34
+ @store = store
35
+ end
36
+
37
+ # Reconcile: upsert the declaration's row, or delete it when the
38
+ # declaration is gone/malformed. Called by the engine inside its pass
39
+ # transaction. A NEW or CHANGED declaration recomputes `next_fire_at`
40
+ # (the trigger lattice starts fresh); an unchanged row keeps its.
41
+ def sync_declared(tenant:, agent:, schedules: nil, now: Time.now.utc)
42
+ kept = []
43
+ Array(schedules).each do |declaration|
44
+ schedule = Insika::Schedule.parse(declaration)
45
+ next if schedule.nil? # malformed — dropped, the doctor names it
46
+
47
+ keep = upsert(tenant: tenant, agent: agent, schedule: schedule, now: now)
48
+ kept << schedule.id if keep
49
+ end
50
+
51
+ # drop rows the agent no longer declares
52
+ prefix = "#{tenant_id(tenant)}:#{agent}:"
53
+ @store.list(SCOPE).each do |k|
54
+ next unless k.start_with?(prefix)
55
+ next if kept.include?(k.split(":").last)
56
+
57
+ @store.delete(SCOPE, k)
58
+ end
59
+ kept
60
+ end
61
+
62
+ # -> Record | nil
63
+ def find(tenant:, agent:, id:)
64
+ record = @store.get(SCOPE, key(tenant, agent, id))
65
+ record && to_record(record)
66
+ end
67
+
68
+ # -> [Record] — one agent's rows (the Studio's read), lexicographic.
69
+ def for_agent(tenant:, agent:)
70
+ prefix = "#{tenant_id(tenant)}:#{agent}:"
71
+ @store.list(SCOPE).filter_map do |k|
72
+ next unless k.start_with?(prefix)
73
+
74
+ to_record(@store.get(SCOPE, k))
75
+ end
76
+ end
77
+
78
+ # -> [Record] — every row (the Studio grid, the engine pass).
79
+ def all
80
+ @store.list(SCOPE).filter_map { |k| to_record(@store.get(SCOPE, k)) }
81
+ end
82
+
83
+ # -> [Record] — enabled AND due, oldest first (determinism). A row whose
84
+ # next_fire_at is nil (a cron that can never fire) is never due.
85
+ def due(now: Time.now.utc)
86
+ cutoff = now.iso8601
87
+ all.select { |r| r.enabled && !r.next_fire_at.to_s.empty? && r.next_fire_at.to_s <= cutoff }
88
+ .sort_by { |r| [r.next_fire_at.to_s, r.id] }
89
+ end
90
+
91
+ # The engine's atomic fire claim — the LAST step of the fire's
92
+ # transaction: advance the lattice, stamp the run, clear the skip.
93
+ # Callers pass `id` and the engine's OWN transaction encloses this.
94
+ def transition_fire(id:, tenant:, agent:, task_id:, next_fire_at:, now: Time.now.utc)
95
+ mutate(tenant, agent, id, now) do |record|
96
+ record["last_run_at"] = now.utc.iso8601
97
+ record["last_task_id"] = task_id.to_s
98
+ record["next_fire_at"] = next_fire_at.iso8601
99
+ record["last_skip"] = nil
100
+ end
101
+ end
102
+
103
+ # The visible skip — recorded, never silent: `next_fire_at` advances
104
+ # (the window is skipped, not queued) and `last_skip` names why.
105
+ def mark_skip(id:, tenant:, agent:, reason:, next_fire_at:, now: Time.now.utc)
106
+ mutate(tenant, agent, id, now) do |record|
107
+ record["last_skip"] = { "at" => now.utc.iso8601, "reason" => reason.to_s }
108
+ record["next_fire_at"] = next_fire_at.iso8601
109
+ end
110
+ end
111
+
112
+ def delete(tenant:, agent:, id:)
113
+ @store.delete(SCOPE, key(tenant, agent, id))
114
+ end
115
+
116
+ # -> count removed. The LGPD sweep (a tenant's schedules die with it).
117
+ def purge(tenant:)
118
+ prefix = "#{tenant_id(tenant)}:"
119
+ keys = @store.list(SCOPE).select { |k| k.start_with?(prefix) }
120
+ keys.each { |k| @store.delete(SCOPE, k) }
121
+ keys.size
122
+ end
123
+
124
+ private
125
+
126
+ # Create or update ONE row from the declaration, recomputing next_fire_at
127
+ # on create and on any trigger/definition change. -> the schedule id.
128
+ def upsert(tenant:, agent:, schedule:, now:)
129
+ k = key(tenant, agent, schedule.id)
130
+ record = @store.get(SCOPE, k)
131
+ return schedule.id if record && !changed?(record, schedule)
132
+
133
+ next_fire_at = initial_next_fire(schedule, now)
134
+ row = {
135
+ "id" => schedule.id, "tenant" => tenant_id(tenant), "agent" => agent.to_s,
136
+ "every" => schedule.every, "cron" => schedule.cron, "tz" => schedule.tz,
137
+ "message" => schedule.message, "session_mode" => schedule.session_mode,
138
+ "session_id" => schedule.session_id, "overrides" => schedule.overrides,
139
+ "enabled" => schedule.enabled,
140
+ "next_fire_at" => next_fire_at&.iso8601,
141
+ "last_run_at" => record&.dig("last_run_at"),
142
+ "last_task_id" => record&.dig("last_task_id"),
143
+ "last_skip" => record&.dig("last_skip"),
144
+ "created_at" => record ? record["created_at"] : now.iso8601,
145
+ "updated_at" => now.iso8601
146
+ }
147
+ @store.set(SCOPE, k, row)
148
+ schedule.id
149
+ end
150
+
151
+ # Does the row's DECLARATION differ from the schedule? (runtime state is
152
+ # never part of the comparison.)
153
+ def changed?(record, schedule)
154
+ record["cron"] != schedule.cron || record["every"] != schedule.every ||
155
+ record["tz"] != schedule.tz || record["message"] != schedule.message ||
156
+ record["session_mode"] != schedule.session_mode ||
157
+ record["session_id"] != schedule.session_id ||
158
+ record["overrides"] != schedule.overrides ||
159
+ record["enabled"] != schedule.enabled
160
+ end
161
+
162
+ # The first fire after `now` for a schedule that has none yet: for `every`,
163
+ # one interval out; for cron, the next expression match. nil (a cron that
164
+ # can never fire) makes the row never due — `due` ignores a nil.
165
+ def initial_next_fire(schedule, now)
166
+ if schedule.every
167
+ now + schedule.every
168
+ else
169
+ Insika::Cron.new(schedule.cron).next_after(now, tz: schedule.tz)
170
+ end
171
+ end
172
+
173
+ def mutate(tenant, agent, id, now)
174
+ @store.transaction do
175
+ record = @store.get(SCOPE, key(tenant, agent, id))
176
+ raise Insika::NotFoundError, "schedule not found: #{tenant}:#{agent}:#{id}" if record.nil?
177
+
178
+ yield record
179
+ record["updated_at"] = now.utc.iso8601
180
+ @store.set(SCOPE, key(tenant, agent, id), record)
181
+ to_record(record)
182
+ end
183
+ end
184
+
185
+ def key(tenant, agent, id)
186
+ "#{tenant_id(tenant)}:#{agent.to_s}:#{id.to_s}"
187
+ end
188
+
189
+ def tenant_id(tenant)
190
+ t = tenant.to_s
191
+ t.empty? ? "platform" : t
192
+ end
193
+
194
+ def to_record(rec)
195
+ return nil if rec.nil?
196
+
197
+ Record.new(id: rec["id"], tenant: rec["tenant"], agent: rec["agent"],
198
+ every: rec["every"], cron: rec["cron"], tz: rec["tz"],
199
+ message: rec["message"], session_mode: rec["session_mode"],
200
+ session_id: rec["session_id"], overrides: rec["overrides"],
201
+ enabled: rec["enabled"] != false,
202
+ next_fire_at: rec["next_fire_at"],
203
+ last_run_at: rec["last_run_at"],
204
+ last_task_id: rec["last_task_id"], last_skip: rec["last_skip"],
205
+ created_at: rec["created_at"], updated_at: rec["updated_at"])
206
+ end
207
+ end
208
+ end