Class: Insika::PendingActionStore

Inherits:
Object
  • Object
show all
Includes:
Coercion
Defined in:
lib/insika/pending_action_store.rb

Overview

Domain store for PENDING approval ACTIONS ("state as a record, not a flag"). A tool marked approval (Policy) creates a PendingAction and the turn goes to :waiting; the operator resolves it via ApproveAction. Durable (over an injected Insika::Store): survives a kill -9 — the operator approves after the reboot, and Recovery rehydrates the task in :waiting.

Normalizes symbol→string on WRITE (the backend only guarantees round-trip of JSON types), like the other domain stores.

Defined Under Namespace

Classes: PendingAction

Constant Summary collapse

SCOPE =
"pending_actions"
KEY_PREFIX =
"pending:"
STATUSES =
%i[pending approved rejected].freeze

Constants included from Coercion

Coercion::TRUTHY

Instance Method Summary collapse

Methods included from Coercion

blank?, deep_stringify, presence, present?, truthy?, utf8

Constructor Details

#initialize(store:) ⇒ PendingActionStore

Returns a new instance of PendingActionStore.



26
27
28
# File 'lib/insika/pending_action_store.rb', line 26

def initialize(store:)
  @store = store
end

Instance Method Details

#all_openObject

-> [PendingAction] every :pending across all tasks — the approvals inbox (Studio). Single O(n) scan (vs. open_for per task = O(n·m)); the UI resolves task context afterwards via TaskStore#find.



69
70
71
72
73
74
75
76
77
# File 'lib/insika/pending_action_store.rb', line 69

def all_open
  @store.list(SCOPE, KEY_PREFIX).filter_map do |key|
    record = @store.get(SCOPE, key)
    next if record.nil?

    pa = to_pending(record)
    pa if pa.status == :pending
  end
end

#create(task_id:, turn:, tool:, args: {}, id: SecureRandom.uuid) ⇒ Object

-> PendingAction (:pending). args is the Hash of the tool call's arguments.



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/insika/pending_action_store.rb', line 31

def create(task_id:, turn:, tool:, args: {}, id: SecureRandom.uuid)
  record = {
    "id" => id.to_s,
    "task_id" => task_id.to_s,
    "turn" => turn,
    "tool" => tool.to_s,
    "args" => deep_stringify(args),
    "status" => "pending",
    "requested_at" => timestamp,
    "resolved_by" => nil,
    "resolved_at" => nil
  }
  @store.set(SCOPE, key_for(id), record)
  to_pending(record)
end

#find(id) ⇒ Object

-> PendingAction | nil



48
49
50
51
# File 'lib/insika/pending_action_store.rb', line 48

def find(id)
  record = @store.get(SCOPE, key_for(id))
  record && to_pending(record)
end

#open_for(task_id) ⇒ Object

-> [PendingAction] :pending for the task (recovery/UI). O(n) scan — single-node, like TaskStore#running_or_interrupted.



55
56
57
58
59
60
61
62
63
64
# File 'lib/insika/pending_action_store.rb', line 55

def open_for(task_id)
  id = task_id.to_s
  @store.list(SCOPE, KEY_PREFIX).filter_map do |key|
    record = @store.get(SCOPE, key)
    next if record.nil?

    pa = to_pending(record)
    pa if pa.status == :pending && pa.task_id == id
  end
end

#resolve(id, decision:, operator: nil) ⇒ Object

-> resolved PendingAction. Only resolves :pending: a double resolution or an invalid decision -> ValidationError; absent -> NotFoundError.



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/insika/pending_action_store.rb', line 81

def resolve(id, decision:, operator: nil)
  target = decision.to_sym
  unless %i[approved rejected].include?(target)
    raise Insika::ValidationError, "invalid decision: #{decision} (approved|rejected)"
  end

  record = @store.get(SCOPE, key_for(id)) ||
           (raise Insika::NotFoundError, "pending action not found: #{id}")
  unless record["status"] == "pending"
    raise Insika::ValidationError, "pending action '#{id}' already resolved (#{record["status"]})"
  end

  record["status"] = target.to_s
  record["resolved_by"] = operator&.to_s
  record["resolved_at"] = timestamp
  @store.set(SCOPE, key_for(id), record)
  to_pending(record)
end