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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +180 -0
- data/README.md +45 -10
- data/bin/insika +684 -0
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +94 -403
- data/docs/API.md +5 -5
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +14 -14
- data/docs/CONTEXT.md +9 -7
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +71 -3
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +128 -3
- data/docs/FACTS.md +3 -3
- data/docs/HARVEST.md +5 -6
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +2 -2
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +15 -10
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +14 -9
- data/docs/RELEASING.md +4 -4
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +3 -3
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +22 -6
- data/docs/SKILLS.md +11 -2
- data/docs/SOAK.md +2 -2
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +152 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +4 -4
- data/docs/improve.md +20 -0
- data/docs/index.md +8 -5
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +73 -16
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/channel_delivery.rb +1 -1
- data/lib/insika/chat_builder.rb +22 -2
- data/lib/insika/commands/agent_payload.rb +2 -2
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +15 -3
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/context/priority.rb +2 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/prompt.rb +30 -24
- data/lib/insika/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/doctor.rb +176 -8
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +60 -79
- data/lib/insika/dsl/server_boot.rb +23 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +103 -2
- data/lib/insika/env_schema.rb +16 -1
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +11 -0
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/executor.rb +231 -55
- data/lib/insika/followup_policy.rb +2 -25
- data/lib/insika/golden_store.rb +16 -1
- data/lib/insika/grounding/matcher.rb +1 -1
- data/lib/insika/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +115 -31
- data/lib/insika/message_origin.rb +1 -1
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/onboarding.rb +17 -1
- data/lib/insika/outcome_store.rb +1 -1
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +2 -2
- data/lib/insika/profile_source.rb +8 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/retention.rb +36 -1
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -0
- data/lib/insika/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/server/app.rb +105 -15
- data/lib/insika/server/rack_app.rb +5 -1
- data/lib/insika/server/responses.rb +1 -1
- data/lib/insika/skill_catalog.rb +12 -0
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/studio/app.rb +567 -45
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +21 -21
- data/lib/insika/studio/forms.rb +46 -5
- data/lib/insika/studio/nav_icons.rb +14 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +61 -820
- data/lib/insika/studio/views/agents.erb +70 -57
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/evals.erb +2 -2
- data/lib/insika/studio/views/facts.erb +1 -1
- data/lib/insika/studio/views/funnel.erb +1 -1
- data/lib/insika/studio/views/home.erb +106 -67
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +14 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/session.erb +231 -177
- data/lib/insika/studio/views/settings.erb +39 -1
- data/lib/insika/studio/views/skills.erb +1 -1
- data/lib/insika/studio/views/tools.erb +24 -9
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/tick.rb +24 -12
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tools/generate_image.rb +52 -7
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +15 -4
- data/lib/insika/version.rb +1 -1
- data/lib/insika/wiring/graph.rb +184 -12
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +57 -0
- metadata +105 -5
- data/docs/build.md +0 -14
- 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
|