aidp 0.47.1 → 0.48.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.
data/lib/aidp/database.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "sqlite3"
4
4
  require "json"
5
5
  require "fileutils"
6
+ Kernel.require("set") unless defined?(Set)
6
7
 
7
8
  module Aidp
8
9
  # Database module for SQLite-based storage
@@ -15,6 +16,8 @@ module Aidp
15
16
  # Thread-safe connection cache
16
17
  @connections = {}
17
18
  @mutex = Mutex.new
19
+ @migrated_projects = Set.new
20
+ @migration_mutex = Mutex.new
18
21
 
19
22
  class << self
20
23
  # Get or create a database connection for the given project directory
@@ -26,6 +29,8 @@ module Aidp
26
29
  db_path = ConfigPaths.database_file(project_dir)
27
30
 
28
31
  @mutex.synchronize do
32
+ invalidate_missing_connection!(db_path)
33
+
29
34
  # Return cached connection if valid
30
35
  if @connections[db_path]&.closed? == false
31
36
  return @connections[db_path]
@@ -53,6 +58,23 @@ module Aidp
53
58
  Migrations.run!(project_dir)
54
59
  end
55
60
 
61
+ # Run pending migrations at most once per process for each project directory.
62
+ #
63
+ # @param project_dir [String] Project directory path
64
+ # @return [Array<Integer>] List of applied migration versions
65
+ def migrate_once!(project_dir = Dir.pwd)
66
+ expanded_project_dir = File.expand_path(project_dir)
67
+ require_relative "database/migrations"
68
+
69
+ @migration_mutex.synchronize do
70
+ return [] if migration_current?(expanded_project_dir)
71
+
72
+ migrate!(expanded_project_dir).tap do
73
+ @migrated_projects << expanded_project_dir
74
+ end
75
+ end
76
+ end
77
+
56
78
  # Check if database exists and is initialized
57
79
  #
58
80
  # @param project_dir [String] Project directory path
@@ -134,6 +156,22 @@ module Aidp
134
156
  # Set busy timeout to 5 seconds
135
157
  db.busy_timeout = 5000
136
158
  end
159
+
160
+ def invalidate_missing_connection!(db_path)
161
+ db = @connections[db_path]
162
+ return unless db
163
+ return if db.closed?
164
+ return if File.exist?(db_path)
165
+
166
+ db.close
167
+ @connections.delete(db_path)
168
+ end
169
+
170
+ def migration_current?(project_dir)
171
+ @migrated_projects.include?(project_dir) &&
172
+ exists?(project_dir) &&
173
+ !Migrations.pending?(project_dir)
174
+ end
137
175
  end
138
176
  end
139
177
  end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "shellwords"
6
+ require "tmpdir"
7
+ require "fileutils"
8
+ require "pathname"
9
+
10
+ module Aidp
11
+ module StrategyExecution
12
+ module CliProtocol
13
+ class ProtocolError < StandardError; end
14
+
15
+ class Runner
16
+ PROTOCOL_VERSION = "1.0"
17
+
18
+ def initialize(project_dir: Dir.pwd)
19
+ @project_dir = project_dir
20
+ end
21
+
22
+ def execute(command:, role:, request:)
23
+ parts = command_parts(command)
24
+ raise ProtocolError, "command must not be blank" if parts.empty?
25
+
26
+ artifact_dir = create_artifact_dir(role)
27
+ payload = request.merge(
28
+ protocol_version: PROTOCOL_VERSION,
29
+ role: role,
30
+ artifact_dir: artifact_dir
31
+ )
32
+
33
+ Aidp.log_debug("cli_protocol", "executing",
34
+ role: role,
35
+ command: parts.first,
36
+ task_id: request[:task]&.dig(:id),
37
+ artifact_dir: artifact_dir)
38
+
39
+ stdout, stderr, status = Open3.capture3(*parts, stdin_data: JSON.generate(payload), chdir: @project_dir)
40
+ unless status.success?
41
+ Aidp.log_error("cli_protocol", "execution_failed",
42
+ role: role,
43
+ command: parts.first,
44
+ exit_status: status.exitstatus,
45
+ stderr: stderr.to_s.strip)
46
+ raise ProtocolError, stderr.strip
47
+ end
48
+
49
+ response = JSON.parse(stdout, symbolize_names: true)
50
+ validate_response!(response, role)
51
+
52
+ response.merge(
53
+ artifacts: normalize_artifacts(response[:artifacts], artifact_dir),
54
+ artifact_dir: artifact_dir,
55
+ stderr: stderr
56
+ )
57
+ rescue JSON::ParserError => e
58
+ Aidp.log_error("cli_protocol", "invalid_response",
59
+ role: role,
60
+ error: e.message)
61
+ raise ProtocolError, "Invalid JSON response: #{e.message}"
62
+ rescue ProtocolError
63
+ raise
64
+ rescue Errno::ENOENT, Errno::EACCES => e
65
+ Aidp.log_error("cli_protocol", "execution_failed",
66
+ role: role,
67
+ command: parts.first,
68
+ error: e.message,
69
+ error_class: e.class.name)
70
+ raise ProtocolError, "Command failed: #{e.message}"
71
+ end
72
+
73
+ private
74
+
75
+ def command_parts(command)
76
+ case command
77
+ when Array then command
78
+ else Shellwords.split(command.to_s)
79
+ end
80
+ end
81
+
82
+ def create_artifact_dir(role)
83
+ base_dir = File.join(@project_dir, ".aidp")
84
+ FileUtils.mkdir_p(base_dir)
85
+ dir = Dir.mktmpdir("aidp-#{role}-", base_dir)
86
+ FileUtils.mkdir_p(dir)
87
+ dir
88
+ end
89
+
90
+ def normalize_artifacts(artifacts, artifact_dir)
91
+ expanded_artifact_dir = File.expand_path(artifact_dir)
92
+
93
+ Array(artifacts).map do |artifact|
94
+ next artifact unless artifact.is_a?(String)
95
+
96
+ expanded_path = File.expand_path(artifact, expanded_artifact_dir)
97
+ validate_artifact_path!(artifact, expanded_path, expanded_artifact_dir)
98
+ expanded_path
99
+ end
100
+ end
101
+
102
+ def validate_artifact_path!(artifact, expanded_path, artifact_dir)
103
+ return if expanded_path == artifact_dir
104
+ return if expanded_path.start_with?("#{artifact_dir}#{File::SEPARATOR}")
105
+
106
+ raise ProtocolError, "artifact path escapes artifact directory: #{artifact}"
107
+ end
108
+
109
+ def validate_response!(response, role)
110
+ raise ProtocolError, "response must include success" unless response.key?(:success)
111
+ return unless role == "evaluator"
112
+ return if response.key?(:score) || response.key?(:passed)
113
+
114
+ raise ProtocolError, "evaluator response must include score or passed"
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../database"
5
+ require_relative "../database/repositories/strategy_repository"
6
+ require_relative "../database/repositories/experience_task_repository"
7
+ require_relative "../database/repositories/experience_run_repository"
8
+ require_relative "../database/repositories/experience_evaluation_repository"
9
+ require_relative "../database/repositories/experience_artifact_repository"
10
+
11
+ module Aidp
12
+ module StrategyExecution
13
+ class ExperienceStore
14
+ def initialize(project_dir: Dir.pwd)
15
+ @project_dir = project_dir
16
+ Aidp::Database.migrate_once!(@project_dir)
17
+ end
18
+
19
+ def register_strategy(strategy_spec)
20
+ record = strategy_repository.register(
21
+ name: strategy_spec.name,
22
+ spec: JSON.generate(strategy_spec.to_h)
23
+ )
24
+ Aidp.log_debug("experience_store", "strategy_registered",
25
+ strategy_id: record[:id], name: strategy_spec.name)
26
+ record
27
+ end
28
+
29
+ def create_task(description:, title: nil, input_payload: {}, context: {}, source_run_id: nil)
30
+ task = task_repository.create(
31
+ description: description,
32
+ title: title,
33
+ input_payload: input_payload,
34
+ context: context,
35
+ source_run_id: source_run_id
36
+ )
37
+ Aidp.log_debug("experience_store", "task_created",
38
+ task_id: task[:id], source_run_id: source_run_id)
39
+ task
40
+ end
41
+
42
+ def start_run(task_id:, strategy_id:, workflow_id:, depth:, input_payload:, parent_run_id: nil, branch_key: nil, metadata: {})
43
+ run = run_repository.start(
44
+ task_id: task_id,
45
+ strategy_id: strategy_id,
46
+ workflow_id: workflow_id,
47
+ depth: depth,
48
+ input_payload: input_payload,
49
+ parent_run_id: parent_run_id,
50
+ branch_key: branch_key,
51
+ metadata: metadata
52
+ )
53
+ Aidp.log_debug("experience_store", "run_started",
54
+ run_id: run[:id],
55
+ task_id: task_id,
56
+ strategy_id: strategy_id,
57
+ branch_key: branch_key,
58
+ depth: depth)
59
+ run
60
+ end
61
+
62
+ def complete_run(run_id:, status:, output_payload:, metadata: {})
63
+ run = run_repository.complete(
64
+ run_id: run_id,
65
+ status: status,
66
+ output_payload: output_payload,
67
+ metadata: metadata
68
+ )
69
+ Aidp.log_debug("experience_store", "run_completed",
70
+ run_id: run_id, status: status)
71
+ run
72
+ end
73
+
74
+ def record_evaluation(run_id:, evaluator_name:, score:, passed:, summary: nil, metadata: {})
75
+ evaluation_repository.record(
76
+ run_id: run_id,
77
+ evaluator_name: evaluator_name,
78
+ score: score,
79
+ passed: passed,
80
+ summary: summary,
81
+ metadata: metadata
82
+ )
83
+ Aidp.log_debug("experience_store", "evaluation_recorded",
84
+ run_id: run_id,
85
+ evaluator_name: evaluator_name,
86
+ score: score,
87
+ passed: passed)
88
+ end
89
+
90
+ def record_artifact(run_id:, role:, path:, metadata: {})
91
+ artifact_repository.record(
92
+ run_id: run_id,
93
+ role: role,
94
+ path: path,
95
+ metadata: metadata
96
+ )
97
+ Aidp.log_debug("experience_store", "artifact_recorded",
98
+ run_id: run_id, role: role, path: path)
99
+ end
100
+
101
+ def replay_bundle(run_id)
102
+ bundle = run_repository.bundle_for_replay(run_id)
103
+ Aidp.log_debug("experience_store", "replay_bundle_loaded",
104
+ run_id: run_id, found: !bundle.nil?)
105
+ bundle
106
+ end
107
+
108
+ def task_details(task_id)
109
+ task = task_repository.load(task_id)
110
+ Aidp.log_debug("experience_store", "task_details_loaded",
111
+ task_id: task_id, found: !task.nil?)
112
+ task
113
+ end
114
+
115
+ def run_details(run_id)
116
+ run = run_repository.load(run_id)
117
+ return nil unless run
118
+
119
+ details = run.merge(
120
+ evaluations: evaluation_repository.list_for_run(run_id),
121
+ artifacts: artifact_repository.list_for_run(run_id),
122
+ children: run_repository.children(run_id)
123
+ )
124
+ Aidp.log_debug("experience_store", "run_details_loaded",
125
+ run_id: run_id,
126
+ evaluation_count: details[:evaluations].length,
127
+ artifact_count: details[:artifacts].length,
128
+ child_count: details[:children].length)
129
+ details
130
+ end
131
+
132
+ private
133
+
134
+ def strategy_repository
135
+ @strategy_repository ||= Aidp::Database::Repositories::StrategyRepository.new(project_dir: @project_dir)
136
+ end
137
+
138
+ def task_repository
139
+ @task_repository ||= Aidp::Database::Repositories::ExperienceTaskRepository.new(project_dir: @project_dir)
140
+ end
141
+
142
+ def run_repository
143
+ @run_repository ||= Aidp::Database::Repositories::ExperienceRunRepository.new(project_dir: @project_dir)
144
+ end
145
+
146
+ def evaluation_repository
147
+ @evaluation_repository ||= Aidp::Database::Repositories::ExperienceEvaluationRepository.new(project_dir: @project_dir)
148
+ end
149
+
150
+ def artifact_repository
151
+ @artifact_repository ||= Aidp::Database::Repositories::ExperienceArtifactRepository.new(project_dir: @project_dir)
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "yaml"
5
+ require_relative "strategy_spec"
6
+
7
+ module Aidp
8
+ module StrategyExecution
9
+ class StrategyLoader
10
+ def initialize(project_dir: Dir.pwd)
11
+ @project_dir = project_dir
12
+ end
13
+
14
+ def load_file(path)
15
+ raw = YAML.safe_load_file(expand_path(path), permitted_classes: [Date, Time, Symbol], aliases: true) || {}
16
+ StrategySpec.from_hash(raw)
17
+ end
18
+
19
+ private
20
+
21
+ def expand_path(path)
22
+ File.expand_path(path, @project_dir)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ module StrategyExecution
5
+ class StrategySpec
6
+ DEFAULT_MAX_DEPTH = 1
7
+ DEFAULT_FANOUT = 1
8
+ DEFAULT_MERGE_POLICY = "highest_score"
9
+
10
+ attr_reader :name, :max_depth, :fanout, :agents, :evaluators, :merge_policy, :constraints, :timeouts
11
+
12
+ def self.from_hash(data)
13
+ new(data.transform_keys(&:to_sym))
14
+ end
15
+
16
+ def initialize(data)
17
+ @name = fetch_name(data)
18
+ @max_depth = positive_integer(data[:max_depth], DEFAULT_MAX_DEPTH)
19
+ @fanout = positive_integer(data[:fanout], DEFAULT_FANOUT)
20
+ @agents = normalize_agents(data[:agents] || {})
21
+ @evaluators = normalize_evaluators(data[:evaluators] || [])
22
+ @merge_policy = (data[:merge_policy] || DEFAULT_MERGE_POLICY).to_s
23
+ @constraints = (data[:constraints] || {}).transform_keys(&:to_sym)
24
+ @timeouts = (data[:timeouts] || {}).transform_keys(&:to_sym)
25
+ ensure_runnable!
26
+ end
27
+
28
+ def to_h
29
+ {
30
+ name: name,
31
+ max_depth: max_depth,
32
+ fanout: fanout,
33
+ agents: agents,
34
+ evaluators: evaluators,
35
+ merge_policy: merge_policy,
36
+ constraints: constraints,
37
+ timeouts: timeouts
38
+ }
39
+ end
40
+
41
+ def branch_commands
42
+ commands = candidate_agent_commands
43
+ Array.new(fanout) do |index|
44
+ command = commands[index % commands.length]
45
+ {
46
+ key: "branch_#{index}",
47
+ name: command[:name],
48
+ command: command[:command]
49
+ }
50
+ end
51
+ end
52
+
53
+ def evaluator_definitions
54
+ evaluators
55
+ end
56
+
57
+ private
58
+
59
+ def fetch_name(data)
60
+ value = data[:name].to_s.strip
61
+ raise ArgumentError, "strategy name is required" if value.empty?
62
+
63
+ value
64
+ end
65
+
66
+ def positive_integer(value, fallback)
67
+ number = value.to_i
68
+ number.positive? ? number : fallback
69
+ end
70
+
71
+ def normalize_agents(agents)
72
+ agents.each_with_object({}) do |(role, value), normalized|
73
+ normalized[role.to_sym] = case value
74
+ when Array
75
+ value.map { |item| normalize_agent_entry(role, item) }
76
+ else
77
+ normalize_agent_entry(role, value)
78
+ end
79
+ end
80
+ end
81
+
82
+ def normalize_agent_entry(role, value)
83
+ case value
84
+ when Hash
85
+ {
86
+ name: (value[:name] || value["name"] || role).to_s,
87
+ command: (value[:command] || value["command"]).to_s
88
+ }
89
+ else
90
+ {
91
+ name: role.to_s,
92
+ command: value.to_s
93
+ }
94
+ end
95
+ end
96
+
97
+ def normalize_evaluators(evaluators)
98
+ Array(evaluators).map do |entry|
99
+ case entry
100
+ when Hash
101
+ {
102
+ name: (entry[:name] || entry["name"]).to_s,
103
+ command: (entry[:command] || entry["command"] || entry[:name] || entry["name"]).to_s
104
+ }
105
+ else
106
+ {
107
+ name: entry.to_s,
108
+ command: entry.to_s
109
+ }
110
+ end
111
+ end
112
+ end
113
+
114
+ def candidate_agent_commands
115
+ raw = agents[:coder] || agents[:execution] || agents.values
116
+ commands = raw.is_a?(Hash) ? [raw] : Array(raw).flatten
117
+ commands.reject { |entry| entry[:command].to_s.empty? }
118
+ end
119
+
120
+ def ensure_runnable!
121
+ return unless candidate_agent_commands.empty?
122
+
123
+ raise ArgumentError, "strategy '#{name}' requires at least one agent with a non-empty command"
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_activity"
4
+ require_relative "../../strategy_execution/cli_protocol"
5
+
6
+ module Aidp
7
+ module Temporal
8
+ module Activities
9
+ class ExecuteCliCommandActivity < BaseActivity
10
+ def execute(input)
11
+ with_activity_context do
12
+ project_dir = input[:project_dir]
13
+ command = input[:command]
14
+ role = input[:role]
15
+ request = input[:request] || {}
16
+
17
+ runner = Aidp::StrategyExecution::CliProtocol::Runner.new(project_dir: project_dir)
18
+
19
+ # The CLI invocation blocks for the full duration of the external
20
+ # agent/evaluator (often minutes). Heartbeat on a background thread so
21
+ # Temporal does not mark the activity timed out and retry the
22
+ # side-effecting command while the original subprocess is still running.
23
+ heartbeat_thread = start_heartbeat_thread(role: role)
24
+ begin
25
+ runner.execute(command: command, role: role, request: request)
26
+ ensure
27
+ heartbeat_thread&.kill
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ # Interval between heartbeats; kept well below the workflow's
35
+ # heartbeat_timeout so a missed beat does not time out the activity.
36
+ def heartbeat_interval_seconds
37
+ 30
38
+ end
39
+
40
+ def start_heartbeat_thread(role:)
41
+ Thread.new do
42
+ loop do
43
+ sleep heartbeat_interval_seconds
44
+ heartbeat(role: role, status: "running")
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_activity"
4
+ require_relative "../../strategy_execution/experience_store"
5
+ require_relative "../../strategy_execution/strategy_spec"
6
+
7
+ module Aidp
8
+ module Temporal
9
+ module Activities
10
+ class ManageExperienceStoreActivity < BaseActivity
11
+ def execute(input)
12
+ with_activity_context do
13
+ project_dir = input[:project_dir]
14
+ operation = input[:operation]
15
+ payload = input[:payload] || {}
16
+ store = Aidp::StrategyExecution::ExperienceStore.new(project_dir: project_dir)
17
+
18
+ Aidp.log_debug("manage_experience_store_activity", "dispatching",
19
+ operation: operation,
20
+ run_id: payload[:run_id],
21
+ task_id: payload[:task_id],
22
+ strategy_id: payload[:strategy_id])
23
+
24
+ case operation
25
+ when "register_strategy"
26
+ strategy = Aidp::StrategyExecution::StrategySpec.from_hash(payload[:strategy])
27
+ store.register_strategy(strategy)
28
+ when "create_task"
29
+ store.create_task(**payload)
30
+ when "start_run"
31
+ store.start_run(**payload)
32
+ when "complete_run"
33
+ store.complete_run(**payload)
34
+ when "record_evaluation"
35
+ store.record_evaluation(**payload)
36
+ success_result
37
+ when "record_artifact"
38
+ store.record_artifact(**payload)
39
+ success_result
40
+ when "task_details"
41
+ store.task_details(payload[:task_id])
42
+ when "replay_bundle"
43
+ store.replay_bundle(payload[:run_id])
44
+ when "run_details"
45
+ store.run_details(payload[:run_id])
46
+ else
47
+ Aidp.log_error("manage_experience_store_activity", "unknown_operation",
48
+ operation: operation)
49
+ error_result("Unknown operation: #{operation}")
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end