insika 0.0.1 → 0.2.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 +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Durable record of one pending OUTBOUND reply. A Shape B
|
|
8
|
+
# channel answers out of band — the turn ends and the reply is POSTed to the
|
|
9
|
+
# platform (or, for a relay, to the consumer's own callback) afterwards — so
|
|
10
|
+
# "the answer exists but the recipient is not on this connection" is exactly
|
|
11
|
+
# the problem already solved for async delegation. This store
|
|
12
|
+
# is DelegationStore's shape with a different recipient, deliberately: a second
|
|
13
|
+
# invention here would be a second thing to get wrong.
|
|
14
|
+
#
|
|
15
|
+
# Lifecycle (never backwards):
|
|
16
|
+
# pending the turn committed a reply; nobody has claimed it yet. This is
|
|
17
|
+
# the ONLY status the boot sweep re-drives.
|
|
18
|
+
# delivering claimed. The claim happens BEFORE the HTTP call, so a crash
|
|
19
|
+
# here loses the delivery rather than duplicating it —
|
|
20
|
+
# at-most-once, the same honest scope as the delegation path.
|
|
21
|
+
# delivered the recipient answered 2xx.
|
|
22
|
+
# failed the bounded retry ran out. Terminal, and NOT re-driven at boot:
|
|
23
|
+
# a third party that refused N times is an operator problem, not
|
|
24
|
+
# something to replay forever.
|
|
25
|
+
#
|
|
26
|
+
# `failed` and `delivering` are a deliberate widening of the RFC's two-status
|
|
27
|
+
# sketch (`pending -> delivered`): without them a crashed claim and an
|
|
28
|
+
# exhausted retry are indistinguishable from a fresh record, and the sweep
|
|
29
|
+
# would redeliver both.
|
|
30
|
+
#
|
|
31
|
+
# Normalizes symbol->string on WRITE (the backend only round-trips JSON types),
|
|
32
|
+
# like every other domain store.
|
|
33
|
+
class OutboxStore
|
|
34
|
+
SCOPE = "outbox"
|
|
35
|
+
KEY_PREFIX = "outbox:"
|
|
36
|
+
|
|
37
|
+
STATUSES = %i[pending delivering delivered failed].freeze
|
|
38
|
+
|
|
39
|
+
Delivery = Data.define(
|
|
40
|
+
:id, :channel, :to, :task_id, :session_id, :payload,
|
|
41
|
+
:status, :attempts, :last_error, :created_at, :updated_at
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def initialize(store:)
|
|
45
|
+
@store = store
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# -> Delivery (:pending). `payload` is the body the channel will send; it is
|
|
49
|
+
# DATA (string keys, JSON types) and the store never interprets it.
|
|
50
|
+
def create(channel:, to:, task_id:, session_id:, payload:, id: SecureRandom.uuid)
|
|
51
|
+
record = {
|
|
52
|
+
"id" => id.to_s,
|
|
53
|
+
"channel" => channel.to_s,
|
|
54
|
+
"to" => to.to_s,
|
|
55
|
+
"task_id" => task_id&.to_s,
|
|
56
|
+
"session_id" => session_id&.to_s,
|
|
57
|
+
"payload" => payload,
|
|
58
|
+
"status" => "pending",
|
|
59
|
+
"attempts" => 0,
|
|
60
|
+
"last_error" => nil,
|
|
61
|
+
"created_at" => timestamp,
|
|
62
|
+
"updated_at" => timestamp
|
|
63
|
+
}
|
|
64
|
+
@store.set(SCOPE, key_for(id), record)
|
|
65
|
+
to_delivery(record)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# -> Delivery | nil
|
|
69
|
+
def find(id)
|
|
70
|
+
record = @store.get(SCOPE, key_for(id))
|
|
71
|
+
record && to_delivery(record)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# -> [Delivery] still waiting for a first claim (boot sweep). Deliberately NOT
|
|
75
|
+
# `delivering`: that one was claimed by a process that then died, and whether
|
|
76
|
+
# its POST landed is unknowable — replaying it is the duplicate the claim
|
|
77
|
+
# exists to prevent.
|
|
78
|
+
def pending
|
|
79
|
+
scan.select { |d| d.status == :pending }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# pending -> delivering, ATOMICALLY — across processes, not just fibers: the
|
|
83
|
+
# read-check-write rides Store#transaction, so two workers claiming the same
|
|
84
|
+
# record serialize on the backend's lock and only one sees :pending. Returns
|
|
85
|
+
# true only for the caller that won the transition; that caller (and only it)
|
|
86
|
+
# makes the HTTP call, so delivery is at-most-once even if the terminal hook
|
|
87
|
+
# and the boot sweep both fire.
|
|
88
|
+
def claim(id)
|
|
89
|
+
@store.transaction do
|
|
90
|
+
record = @store.get(SCOPE, key_for(id))
|
|
91
|
+
next false unless record && record["status"] == "pending"
|
|
92
|
+
|
|
93
|
+
record["status"] = "delivering"
|
|
94
|
+
record["updated_at"] = timestamp
|
|
95
|
+
@store.set(SCOPE, key_for(id), record)
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# One attempt happened and did not succeed. Keeps the record :delivering (the
|
|
101
|
+
# in-process retry loop owns it) and writes down why, so an operator reading
|
|
102
|
+
# the store sees the third party's answer and not just a counter.
|
|
103
|
+
def record_attempt(id, error: nil)
|
|
104
|
+
record = fetch!(id)
|
|
105
|
+
record["attempts"] = record["attempts"].to_i + 1
|
|
106
|
+
record["last_error"] = error&.to_s
|
|
107
|
+
touch(id, record)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# -> Delivery (:delivered). Idempotent: an already-delivered record is
|
|
111
|
+
# returned unchanged rather than re-marked.
|
|
112
|
+
def mark_delivered(id)
|
|
113
|
+
record = fetch!(id)
|
|
114
|
+
return to_delivery(record) if record["status"] == "delivered"
|
|
115
|
+
|
|
116
|
+
record["status"] = "delivered"
|
|
117
|
+
record["attempts"] = record["attempts"].to_i + 1
|
|
118
|
+
record["last_error"] = nil
|
|
119
|
+
touch(id, record)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# -> Delivery (:failed). The retry budget is spent; nothing re-drives this.
|
|
123
|
+
def mark_failed(id, error: nil)
|
|
124
|
+
record = fetch!(id)
|
|
125
|
+
record["status"] = "failed"
|
|
126
|
+
record["last_error"] = error&.to_s if error
|
|
127
|
+
touch(id, record)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def scan
|
|
133
|
+
return enum_for(:scan) unless block_given?
|
|
134
|
+
|
|
135
|
+
@store.list(SCOPE, KEY_PREFIX).each do |key|
|
|
136
|
+
record = @store.get(SCOPE, key)
|
|
137
|
+
yield to_delivery(record) if record
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def fetch!(id)
|
|
142
|
+
@store.get(SCOPE, key_for(id)) ||
|
|
143
|
+
(raise Insika::NotFoundError, "outbox delivery not found: #{id}")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def touch(id, record)
|
|
147
|
+
record["updated_at"] = timestamp
|
|
148
|
+
@store.set(SCOPE, key_for(id), record)
|
|
149
|
+
to_delivery(record)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def key_for(id) = "#{KEY_PREFIX}#{id}"
|
|
153
|
+
|
|
154
|
+
def to_delivery(record)
|
|
155
|
+
Delivery.new(
|
|
156
|
+
id: record["id"], channel: record["channel"], to: record["to"],
|
|
157
|
+
task_id: record["task_id"], session_id: record["session_id"],
|
|
158
|
+
payload: record["payload"], status: record["status"].to_sym,
|
|
159
|
+
attempts: record["attempts"].to_i, last_error: record["last_error"],
|
|
160
|
+
created_at: record["created_at"], updated_at: record["updated_at"]
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def timestamp = Time.now.utc.iso8601
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# DYNAMIC tool registry: composes the CODE registry (base, built at boot,
|
|
5
|
+
# immutable) with the DATA-DEFINED tools from the ToolStore. Drop-in for ToolRegistry —
|
|
6
|
+
# the Executor/ToolCatalog/ToolEnvelope only use entries/resolve/side_effect?.,
|
|
7
|
+
#
|
|
8
|
+
# Rules:
|
|
9
|
+
# - COLLISION: the base (code) ALWAYS wins — a data-tool cannot hijack
|
|
10
|
+
# the name of a code tool (security, R3). The authoring Command also
|
|
11
|
+
# refuses to create with a colliding name (code_tool?), but the defense stays here.
|
|
12
|
+
# - HOT: `reload` re-reads the store and swaps the dynamic index atomically — a
|
|
13
|
+
# new/edited data-tool takes effect on the next turn without a restart, mirroring
|
|
14
|
+
# SkillCatalog.reload. An in-flight turn has already captured the index.
|
|
15
|
+
# PARITY: empty ToolStore ⇒ entries/resolve/side_effect? identical to the
|
|
16
|
+
# pure base. The base (config/wiring.rb) does not even use the overlay — zero regression.
|
|
17
|
+
#
|
|
18
|
+
# The data-tools enter as NORMAL Registry::Entry (optional: false) — they obey
|
|
19
|
+
# the same per-agent allow/deny as code tools; exposure is the operator's
|
|
20
|
+
# (the /tools matrix), not automatic just because they are "data-defined".
|
|
21
|
+
class OverlayToolRegistry
|
|
22
|
+
def initialize(base:, tool_store:, http:, egress: Insika::EgressGuard, egress_options: {}, event_stream: nil)
|
|
23
|
+
@base = base
|
|
24
|
+
@tool_store = tool_store
|
|
25
|
+
@http = http
|
|
26
|
+
@egress = egress
|
|
27
|
+
@egress_options = egress_options
|
|
28
|
+
@event_stream = event_stream
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Base + dynamic, except dynamic ones that collide with the base (base wins).
|
|
32
|
+
def entries
|
|
33
|
+
@base.entries + dynamic.reject { |e| code_tool?(e.name) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def names
|
|
37
|
+
(@base.names + dynamic.map(&:name)).uniq
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# -> instance (base wins) | raise NotFoundError.
|
|
41
|
+
def resolve(name)
|
|
42
|
+
key = name.to_s
|
|
43
|
+
return @base.resolve(key) if code_tool?(key)
|
|
44
|
+
|
|
45
|
+
entry = dynamic.find { |e| e.name == key }
|
|
46
|
+
raise Insika::NotFoundError, "'#{name}' not registered in #{self.class}" unless entry
|
|
47
|
+
|
|
48
|
+
entry.factory.call
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# -> bool; consumed by ToolEnvelope (checkpoint/skip-on-resume). Base wins.
|
|
52
|
+
def side_effect?(name)
|
|
53
|
+
key = name.to_s
|
|
54
|
+
return @base.side_effect?(key) if code_tool?(key)
|
|
55
|
+
|
|
56
|
+
entry = dynamic.find { |e| e.name == key }
|
|
57
|
+
entry ? !!entry.metadata[:side_effect] : false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Atomic swap of the dynamic index (after writing/removing a data-tool).
|
|
61
|
+
def reload
|
|
62
|
+
@dynamic = build_dynamic
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Is it a CODE tool (base)? Used by the authoring Command's validation.
|
|
67
|
+
def code_tool?(name) = @base.names.include?(name.to_s)
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def dynamic
|
|
72
|
+
@dynamic ||= build_dynamic
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_dynamic
|
|
76
|
+
@tool_store.all_raw.filter_map { |raw| entry_for(raw) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def entry_for(raw)
|
|
80
|
+
definition = Insika::ToolDefinition.from_h(raw)
|
|
81
|
+
Insika::Registry::Entry.new(
|
|
82
|
+
name: definition.name, plugin: "data-tools",
|
|
83
|
+
metadata: { optional: false, side_effect: definition.side_effect,
|
|
84
|
+
group: definition.group, tags: definition.tags },
|
|
85
|
+
factory: -> { build_tool(definition) }
|
|
86
|
+
)
|
|
87
|
+
rescue Insika::ValidationError => e
|
|
88
|
+
warn "[overlay-tools] invalid definition ignored: #{e.message}"
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Lazy require: DataDefinedTool inherits from RubyLLM::Tool (pulls in the gem) -> kept
|
|
93
|
+
# out of insika.rb load-time, loaded on the 1st instance (turn time).
|
|
94
|
+
def build_tool(definition)
|
|
95
|
+
require_relative "tools/data_defined_tool"
|
|
96
|
+
Insika::Tools::DataDefinedTool.new(
|
|
97
|
+
definition: definition, http: @http, egress: @egress,
|
|
98
|
+
egress_options: @egress_options, event_stream: @event_stream
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/insika/pack.rb
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
# Provisioning pack: the PORTABLE form of an agent — a
|
|
7
|
+
# manifest + the prompt files + the skills + the data-tool defs. It's what
|
|
8
|
+
# `docs/prompt-base/06` describes as a workspace, here in a value object
|
|
9
|
+
# consumable by the PackImporter (which emits the authoring Commands). GENERIC
|
|
10
|
+
# per project: the engine doesn't know any consumer — the pack is the contract.
|
|
11
|
+
#
|
|
12
|
+
# config: Hash — manifest (AgentProfile.build attrs: id/model/provider/
|
|
13
|
+
# limits/metadata/tools_deferred/…). `id`/`model` required there.
|
|
14
|
+
# files: { "IDENTITY.md" => "<content>", ... } — prompt files (become
|
|
15
|
+
# prompt_files via write_agent_file). File name = key.
|
|
16
|
+
# skills: { "escalation-to-human" => "<SKILL.md>", ... } — 1 per skill.
|
|
17
|
+
# tools: [ { ToolDefinition hash }, ... ] — the pack's data-tool defs.
|
|
18
|
+
#
|
|
19
|
+
# Two sources: `from_h` (GatewayClient JSON — provisioning API) and
|
|
20
|
+
# `from_dir` (a folder on disk per docs/prompt-base/06 — authoring/CLI).
|
|
21
|
+
Pack = Data.define(:config, :files, :skills, :tools) do
|
|
22
|
+
# Raw Hash (string|symbol keys) -> Pack. Tolerates both key conventions
|
|
23
|
+
# (the JSON wire may arrive symbolized or not).
|
|
24
|
+
def self.from_h(hash)
|
|
25
|
+
h = hash || {}
|
|
26
|
+
new(
|
|
27
|
+
config: symbolize(dig(h, :config) || {}),
|
|
28
|
+
files: text_values!(stringify_keys(dig(h, :files) || {}), "files"),
|
|
29
|
+
skills: text_values!(stringify_keys(dig(h, :skills) || {}), "skills"),
|
|
30
|
+
tools: Array(dig(h, :tools))
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A pack's files and skills are NAME => MARKDOWN. Only the keys used to be
|
|
35
|
+
# normalized, so `{"files": {"AGENTS.md": {"content": "…"}}}` — the shape anyone
|
|
36
|
+
# writes first — was accepted and reached the store as Ruby's #inspect of the
|
|
37
|
+
# object. Refuse at the front door, where the name of the offending file is still
|
|
38
|
+
# in hand.
|
|
39
|
+
def self.text_values!(hash, field)
|
|
40
|
+
hash.each do |name, value|
|
|
41
|
+
next if value.is_a?(String)
|
|
42
|
+
|
|
43
|
+
raise Insika::ValidationError,
|
|
44
|
+
"pack #{field}['#{name}'] must be the file's text, got #{value.class} — " \
|
|
45
|
+
"a nested object (e.g. {\"content\": …}) is not unwrapped, it is stored verbatim"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
private_class_method :text_values!
|
|
49
|
+
|
|
50
|
+
# Folder on disk (docs/prompt-base/06): `agent.config.json` + `*.md` at the
|
|
51
|
+
# root + `skills/<name>/SKILL.md` + `tools/*.json` (1 ToolDefinition per file).
|
|
52
|
+
def self.from_dir(path)
|
|
53
|
+
root = path.to_s
|
|
54
|
+
config_file = File.join(root, "agent.config.json")
|
|
55
|
+
raise Insika::ValidationError, "pack missing agent.config.json in #{root}" unless File.exist?(config_file)
|
|
56
|
+
|
|
57
|
+
new(
|
|
58
|
+
config: symbolize(JSON.parse(File.read(config_file, encoding: "UTF-8"))),
|
|
59
|
+
files: read_md_files(root),
|
|
60
|
+
skills: read_skills(File.join(root, "skills")),
|
|
61
|
+
tools: read_tools(File.join(root, "tools"))
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.dig(hash, key) = hash[key] || hash[key.to_s]
|
|
66
|
+
private_class_method :dig
|
|
67
|
+
|
|
68
|
+
def self.symbolize(hash)
|
|
69
|
+
(hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
70
|
+
end
|
|
71
|
+
private_class_method :symbolize
|
|
72
|
+
|
|
73
|
+
def self.stringify_keys(hash)
|
|
74
|
+
(hash || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
75
|
+
end
|
|
76
|
+
private_class_method :stringify_keys
|
|
77
|
+
|
|
78
|
+
# `*.md` at the pack ROOT (doesn't descend into skills/). File name = key.
|
|
79
|
+
def self.read_md_files(root)
|
|
80
|
+
Dir.glob(File.join(root, "*.md")).sort.each_with_object({}) do |file, acc|
|
|
81
|
+
acc[File.basename(file)] = File.read(file, encoding: "UTF-8")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
private_class_method :read_md_files
|
|
85
|
+
|
|
86
|
+
# `skills/<name>/SKILL.md` -> { "<name>" => "<content>" }.
|
|
87
|
+
def self.read_skills(dir)
|
|
88
|
+
Dir.glob(File.join(dir, "*", "SKILL.md")).sort.each_with_object({}) do |file, acc|
|
|
89
|
+
acc[File.basename(File.dirname(file))] = File.read(file, encoding: "UTF-8")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
private_class_method :read_skills
|
|
93
|
+
|
|
94
|
+
# `tools/*.json` -> [ ToolDefinition hash, ... ].
|
|
95
|
+
def self.read_tools(dir)
|
|
96
|
+
Dir.glob(File.join(dir, "*.json")).sort.map do |file|
|
|
97
|
+
JSON.parse(File.read(file, encoding: "UTF-8"))
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
private_class_method :read_tools
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Pack importer: reads a Pack and emits the ALREADY
|
|
5
|
+
# existing authoring Commands — create_agent/update_agent + write_agent_file +
|
|
6
|
+
# write_skill + write_data_tool — making an agent provisionable at runtime from
|
|
7
|
+
# a standardized pack. It's the piece a consumer's provisioning client
|
|
8
|
+
# triggers (via the provisioning API).
|
|
9
|
+
#
|
|
10
|
+
# GENERIC: nothing here mentions a consumer — the pack is the contract. Tool
|
|
11
|
+
# names come from the PACK, so prompts<->tools stay consistent by construction
|
|
12
|
+
# It doesn't write to the store directly: it only dispatches Commands on
|
|
13
|
+
# the bus (the same transport discipline) + READS the ProfileSource to decide
|
|
14
|
+
# create vs update.
|
|
15
|
+
#
|
|
16
|
+
# IDEMPOTENT (upsert): re-importing the same pack reconciles — files/skills/
|
|
17
|
+
# tools rewritten; the allowlists (prompt_files/skills/tools_allow) are
|
|
18
|
+
# AUTHORITATIVE from the pack, so whatever left the pack leaves the agent
|
|
19
|
+
# (isolation and no drift). An in-flight turn keeps the profile it captured.
|
|
20
|
+
class PackImporter
|
|
21
|
+
def initialize(bus:, profiles:)
|
|
22
|
+
@bus = bus
|
|
23
|
+
@profiles = profiles
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# -> { agent_id:, created:, files: [names], skills: [names], tools: [names] }.
|
|
27
|
+
# Validation/lookup errors from the Commands (missing id/model, etc.) propagate
|
|
28
|
+
# (the transport maps them to 422/404).
|
|
29
|
+
def import(pack)
|
|
30
|
+
id = presence(pack.config[:id]) ||
|
|
31
|
+
(raise Insika::ValidationError, "pack missing config.id")
|
|
32
|
+
|
|
33
|
+
created = @profiles[id].nil?
|
|
34
|
+
# create_agent rejects an already-existing id; update_agent requires it to
|
|
35
|
+
# exist — the choice by presence makes the import an upsert.
|
|
36
|
+
dispatch(created ? :create_agent : :update_agent, agent_attrs(pack, id))
|
|
37
|
+
|
|
38
|
+
pack.files.each { |name, body| dispatch(:write_agent_file, { agent_id: id, file: name, content: body }) }
|
|
39
|
+
pack.skills.each { |name, body| dispatch(:write_skill, { name: name, content: body }) }
|
|
40
|
+
pack.tools.each { |defn| dispatch(:write_data_tool, defn) }
|
|
41
|
+
|
|
42
|
+
{ agent_id: id, created: created,
|
|
43
|
+
files: pack.files.keys, skills: pack.skills.keys, tools: pack.tools.map { |t| tool_name(t) } }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Removes the agent (delete_agent). Does NOT delete skills/tools/files: they
|
|
47
|
+
# may be shared and the SkillStore/ToolStore are global — selective removal is
|
|
48
|
+
# operator work. NotFoundError (missing agent) propagates -> 404.
|
|
49
|
+
def delete(id)
|
|
50
|
+
dispatch(:delete_agent, { id: id })
|
|
51
|
+
{ agent_id: id, deleted: true }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# AgentProfile.build attrs: the pack manifest + the allowlists derived and
|
|
57
|
+
# AUTHORITATIVE from the pack. This way the agent only sees the skills/tools of
|
|
58
|
+
# its OWN pack (per-store isolation) and re-provisioning removes what left.
|
|
59
|
+
# - prompt_files = the pack's .md files (write_agent_file also registers;
|
|
60
|
+
# union is a no-op). Setting here makes the list authoritative (removes the ones that left).
|
|
61
|
+
# - skills = the pack's skills/ dirs (explicit allowlist; [] when the pack
|
|
62
|
+
# has none — never nil=all, which would leak skills from other stores).
|
|
63
|
+
# - tools_allow = (config.tools_allow) ∪ (the pack's tool names) — guarantees
|
|
64
|
+
# the agent can call its own data-tools. [] when neither exists —
|
|
65
|
+
# never nil=all: the ToolStore is GLOBAL, so a tool-less pack would
|
|
66
|
+
# otherwise see every other store's tools (store A's pack calling
|
|
67
|
+
# store B's set_location is exactly that leak).
|
|
68
|
+
# - tools_allow_groups = groups enabled by FLAG in the pack (see
|
|
69
|
+
# #enabled_groups) — the per-flag schema CUT: only
|
|
70
|
+
# the tools of the enabled groups (union with tools_allow) go to the model;
|
|
71
|
+
# those of disabled groups are cut BEFORE the turn (resolves the OpenClaw
|
|
72
|
+
# tool-call waste, where the flag only exists in Rails).
|
|
73
|
+
def agent_attrs(pack, id)
|
|
74
|
+
attrs = pack.config.dup
|
|
75
|
+
attrs[:id] = id
|
|
76
|
+
attrs[:prompt_files] = pack.files.keys unless pack.files.empty?
|
|
77
|
+
attrs[:skills] = pack.skills.keys
|
|
78
|
+
|
|
79
|
+
pack_tools = pack.tools.map { |t| tool_name(t) }
|
|
80
|
+
allow = Array(pack.config[:tools_allow]).map(&:to_s) | pack_tools
|
|
81
|
+
attrs[:tools_allow] = allow
|
|
82
|
+
|
|
83
|
+
groups = enabled_groups(pack.config)
|
|
84
|
+
attrs[:tools_allow_groups] = groups unless groups.nil?
|
|
85
|
+
attrs
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# PER-FLAG CUT (/, STATIC pilot): derives the agent's
|
|
89
|
+
# per-group allowlist from FLAGS declared in the pack config — DATA, never a
|
|
90
|
+
# core convention. The engine doesn't know "groceries_v2"/"b2b": the pack
|
|
91
|
+
# declares which GROUPS are enabled; the flag->group mapping is the
|
|
92
|
+
# responsibility of provisioning/the pack, not the insika. Two forms (union):
|
|
93
|
+
# - `enabled_groups: ["default", "b2b"]` — explicit list of ON groups.
|
|
94
|
+
# - `flags: { "wholesale" => true, "outlet" => false }` — the flag key IS the group
|
|
95
|
+
# name; only the truthy ones enter (the false ones CUT the group).
|
|
96
|
+
# Neither declared -> nil (no per-group cut; old behavior).
|
|
97
|
+
# Explicit `[]` (empty enabled_groups, or all flags false) -> no group.
|
|
98
|
+
def enabled_groups(config)
|
|
99
|
+
return nil unless config.key?(:enabled_groups) || config.key?(:flags)
|
|
100
|
+
|
|
101
|
+
from_list = Array(config[:enabled_groups]).map(&:to_s)
|
|
102
|
+
from_flags = flag_groups(config[:flags])
|
|
103
|
+
(from_list | from_flags)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# { group => bool } -> [groups with a truthy value]. Accepts string|symbol keys.
|
|
107
|
+
def flag_groups(flags)
|
|
108
|
+
return [] unless flags.is_a?(Hash)
|
|
109
|
+
|
|
110
|
+
flags.filter_map { |group, on| group.to_s if truthy?(on) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def truthy?(val) = val == true || val.to_s == "true"
|
|
114
|
+
|
|
115
|
+
def tool_name(defn) = (defn[:name] || defn["name"]).to_s
|
|
116
|
+
|
|
117
|
+
def presence(str) = Insika::Coercion.presence(str)
|
|
118
|
+
|
|
119
|
+
def dispatch(type, payload)
|
|
120
|
+
@bus.dispatch(Insika::Command.build(type, payload, transport: :http))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# Domain store for PENDING approval ACTIONS ("state as a
|
|
8
|
+
# record, not a flag"). A tool marked `approval` (Policy) creates a
|
|
9
|
+
# PendingAction and the turn goes to :waiting; the operator resolves it via ApproveAction.
|
|
10
|
+
# Durable (over an injected Insika::Store): survives a kill -9 — the operator
|
|
11
|
+
# approves after the reboot, and Recovery rehydrates the task in :waiting.
|
|
12
|
+
#
|
|
13
|
+
# Normalizes symbol→string on WRITE (the backend only guarantees round-trip of JSON
|
|
14
|
+
# types), like the other domain stores.
|
|
15
|
+
class PendingActionStore
|
|
16
|
+
include Coercion
|
|
17
|
+
|
|
18
|
+
SCOPE = "pending_actions"
|
|
19
|
+
KEY_PREFIX = "pending:"
|
|
20
|
+
|
|
21
|
+
STATUSES = %i[pending approved rejected].freeze
|
|
22
|
+
|
|
23
|
+
PendingAction = Data.define(:id, :task_id, :turn, :tool, :args,
|
|
24
|
+
:status, :requested_at, :resolved_by, :resolved_at)
|
|
25
|
+
|
|
26
|
+
def initialize(store:)
|
|
27
|
+
@store = store
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# -> PendingAction (:pending). `args` is the Hash of the tool call's arguments.
|
|
31
|
+
def create(task_id:, turn:, tool:, args: {}, id: SecureRandom.uuid)
|
|
32
|
+
record = {
|
|
33
|
+
"id" => id.to_s,
|
|
34
|
+
"task_id" => task_id.to_s,
|
|
35
|
+
"turn" => turn,
|
|
36
|
+
"tool" => tool.to_s,
|
|
37
|
+
"args" => deep_stringify(args),
|
|
38
|
+
"status" => "pending",
|
|
39
|
+
"requested_at" => timestamp,
|
|
40
|
+
"resolved_by" => nil,
|
|
41
|
+
"resolved_at" => nil
|
|
42
|
+
}
|
|
43
|
+
@store.set(SCOPE, key_for(id), record)
|
|
44
|
+
to_pending(record)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# -> PendingAction | nil
|
|
48
|
+
def find(id)
|
|
49
|
+
record = @store.get(SCOPE, key_for(id))
|
|
50
|
+
record && to_pending(record)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# -> [PendingAction] :pending for the task (recovery/UI). O(n) scan — single-node,
|
|
54
|
+
# like TaskStore#running_or_interrupted.
|
|
55
|
+
def open_for(task_id)
|
|
56
|
+
id = task_id.to_s
|
|
57
|
+
@store.list(SCOPE, KEY_PREFIX).filter_map do |key|
|
|
58
|
+
record = @store.get(SCOPE, key)
|
|
59
|
+
next if record.nil?
|
|
60
|
+
|
|
61
|
+
pa = to_pending(record)
|
|
62
|
+
pa if pa.status == :pending && pa.task_id == id
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# -> [PendingAction] every :pending across all tasks — the approvals inbox
|
|
67
|
+
# (Studio). Single O(n) scan (vs. open_for per task = O(n·m)); the
|
|
68
|
+
# UI resolves task context afterwards via TaskStore#find.
|
|
69
|
+
def all_open
|
|
70
|
+
@store.list(SCOPE, KEY_PREFIX).filter_map do |key|
|
|
71
|
+
record = @store.get(SCOPE, key)
|
|
72
|
+
next if record.nil?
|
|
73
|
+
|
|
74
|
+
pa = to_pending(record)
|
|
75
|
+
pa if pa.status == :pending
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# -> resolved PendingAction. Only resolves :pending: a double resolution
|
|
80
|
+
# or an invalid decision -> ValidationError; absent -> NotFoundError.
|
|
81
|
+
def resolve(id, decision:, operator: nil)
|
|
82
|
+
target = decision.to_sym
|
|
83
|
+
unless %i[approved rejected].include?(target)
|
|
84
|
+
raise Insika::ValidationError, "invalid decision: #{decision} (approved|rejected)"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
record = @store.get(SCOPE, key_for(id)) ||
|
|
88
|
+
(raise Insika::NotFoundError, "pending action not found: #{id}")
|
|
89
|
+
unless record["status"] == "pending"
|
|
90
|
+
raise Insika::ValidationError, "pending action '#{id}' already resolved (#{record["status"]})"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
record["status"] = target.to_s
|
|
94
|
+
record["resolved_by"] = operator&.to_s
|
|
95
|
+
record["resolved_at"] = timestamp
|
|
96
|
+
@store.set(SCOPE, key_for(id), record)
|
|
97
|
+
to_pending(record)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def key_for(id) = "#{KEY_PREFIX}#{id}"
|
|
103
|
+
|
|
104
|
+
def to_pending(record)
|
|
105
|
+
PendingAction.new(
|
|
106
|
+
id: record["id"],
|
|
107
|
+
task_id: record["task_id"],
|
|
108
|
+
turn: record["turn"],
|
|
109
|
+
tool: record["tool"],
|
|
110
|
+
args: record["args"],
|
|
111
|
+
status: record["status"].to_sym,
|
|
112
|
+
requested_at: record["requested_at"],
|
|
113
|
+
resolved_by: record["resolved_by"],
|
|
114
|
+
resolved_at: record["resolved_at"]
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def timestamp = Time.now.utc.iso8601
|
|
119
|
+
end
|
|
120
|
+
end
|