ocak 0.1.0 → 0.3.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.
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'fileutils'
5
+ require_relative 'pipeline_state'
6
+ require_relative 'run_report'
7
+ require_relative 'verification'
8
+ require_relative 'planner'
9
+ require_relative 'step_comments'
10
+
11
+ module Ocak
12
+ class PipelineExecutor
13
+ include Verification
14
+ include Planner
15
+ include StepComments
16
+
17
+ StepContext = Struct.new(:issue_number, :idx, :role, :result, :state, :logger, :chdir)
18
+
19
+ attr_writer :issues
20
+
21
+ def initialize(config:, issues: nil, shutdown_check: nil)
22
+ @config = config
23
+ @issues = issues
24
+ @shutdown_check = shutdown_check
25
+ end
26
+
27
+ def run_pipeline(issue_number, logger:, claude:, chdir: nil, skip_steps: [], complexity: 'full')
28
+ chdir ||= @config.project_dir
29
+ logger.info("=== Starting pipeline for issue ##{issue_number} (#{complexity}) ===")
30
+
31
+ report = RunReport.new(complexity: complexity)
32
+ state = build_initial_state(complexity, report)
33
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
+ post_pipeline_start_comment(issue_number, state)
35
+
36
+ failure = run_pipeline_steps(issue_number, state, logger: logger, claude: claude, chdir: chdir,
37
+ skip_steps: skip_steps)
38
+ log_cost_summary(state[:total_cost], logger)
39
+
40
+ return handle_interrupted(issue_number, state, report, logger) if state[:interrupted]
41
+ return handle_failure(issue_number, state, failure, report, start_time) if failure
42
+
43
+ failure = run_final_verification(issue_number, logger: logger, claude: claude, chdir: chdir)
44
+ return handle_failure(issue_number, state, failure, report, start_time) if failure
45
+
46
+ pipeline_state.delete(issue_number)
47
+ finish_success(issue_number, state, report, start_time, logger)
48
+ end
49
+
50
+ private
51
+
52
+ def build_initial_state(complexity, report)
53
+ { last_review_output: nil, had_fixes: false, completed_steps: [], total_cost: 0.0,
54
+ complexity: complexity, steps_run: 0, steps_skipped: 0,
55
+ audit_output: nil, audit_blocked: false, report: report }
56
+ end
57
+
58
+ def handle_interrupted(issue_number, state, report, logger)
59
+ save_report(report, issue_number, success: false, failed_phase: 'interrupted')
60
+ logger.info("=== Pipeline interrupted for issue ##{issue_number} ===")
61
+ build_interrupted_result(state)
62
+ end
63
+
64
+ def handle_failure(issue_number, state, failure, report, start_time)
65
+ save_report(report, issue_number, success: false, failed_phase: failure[:phase])
66
+ post_failure_and_return(issue_number, state, failure, start_time)
67
+ end
68
+
69
+ def finish_success(issue_number, state, report, start_time, logger)
70
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
71
+ save_report(report, issue_number, success: true)
72
+ post_pipeline_summary_comment(issue_number, state, duration, success: true)
73
+ logger.info("=== Pipeline complete for issue ##{issue_number} ===")
74
+ { success: true, output: 'Pipeline completed successfully',
75
+ audit_blocked: state[:audit_blocked], audit_output: state[:audit_output] }
76
+ end
77
+
78
+ def build_interrupted_result(state)
79
+ last_step = state[:completed_steps].any? ? @config.steps[state[:completed_steps].last] : nil
80
+ last_role = last_step ? symbolize(last_step)[:role].to_s : 'startup'
81
+ { success: false, phase: last_role, output: 'Pipeline interrupted', interrupted: true }
82
+ end
83
+
84
+ def post_failure_and_return(issue_number, state, failure, start_time)
85
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
86
+ post_pipeline_summary_comment(issue_number, state, duration, success: false,
87
+ failed_phase: failure[:phase])
88
+ failure
89
+ end
90
+
91
+ def run_pipeline_steps(issue_number, state, logger:, claude:, chdir:, skip_steps: [])
92
+ @config.steps.each_with_index do |step, idx|
93
+ break if check_shutdown(state, logger)
94
+
95
+ step = symbolize(step)
96
+ role = step[:role].to_s
97
+ agent = step[:agent].to_s
98
+
99
+ next if handle_already_completed(idx, role, skip_steps, logger)
100
+
101
+ reason = skip_reason(step, state)
102
+ if reason
103
+ logger.info("Skipping #{role} \u2014 #{reason}")
104
+ record_skipped_step(issue_number, state, idx, agent, role, reason)
105
+ next
106
+ end
107
+
108
+ result = execute_step(step, issue_number, state[:last_review_output], logger: logger, claude: claude,
109
+ chdir: chdir)
110
+ state[:report].record_step(index: idx, agent: agent, role: role, status: 'completed', result: result)
111
+ ctx = StepContext.new(issue_number, idx, role, result, state, logger, chdir)
112
+ failure = record_step_result(ctx)
113
+ return failure if failure
114
+ end
115
+ nil
116
+ end
117
+
118
+ def check_shutdown(state, logger)
119
+ return false unless @shutdown_check&.call
120
+
121
+ logger.info('Shutdown requested, stopping pipeline')
122
+ state[:interrupted] = true
123
+ end
124
+
125
+ def handle_already_completed(idx, role, skip_steps, logger)
126
+ return false unless skip_steps.include?(idx)
127
+
128
+ logger.info("Skipping #{role} (already completed)")
129
+ true
130
+ end
131
+
132
+ def record_skipped_step(issue_number, state, idx, agent, role, reason)
133
+ post_step_comment(issue_number, "\u{23ED}\u{FE0F} **Skipping #{role}** \u2014 #{reason}")
134
+ state[:report].record_step(index: idx, agent: agent, role: role, status: 'skipped', skip_reason: reason)
135
+ state[:steps_skipped] += 1
136
+ end
137
+
138
+ def execute_step(step, issue_number, review_output, logger:, claude:, chdir:)
139
+ agent = step[:agent].to_s
140
+ role = step[:role].to_s
141
+ logger.info("--- Phase: #{role} (#{agent}) ---")
142
+ post_step_comment(issue_number, "\u{1F504} **Phase: #{role}** (#{agent})")
143
+ prompt = build_step_prompt(role, issue_number, review_output)
144
+ claude.run_agent(agent.tr('_', '-'), prompt, chdir: chdir)
145
+ end
146
+
147
+ def record_step_result(ctx)
148
+ update_pipeline_state(ctx.role, ctx.result, ctx.state)
149
+ ctx.state[:completed_steps] << ctx.idx
150
+ ctx.state[:steps_run] += 1
151
+ ctx.state[:total_cost] += ctx.result.cost_usd.to_f
152
+ save_step_progress(ctx)
153
+ write_step_output(ctx.issue_number, ctx.idx, ctx.role, ctx.result.output)
154
+ post_step_completion_comment(ctx.issue_number, ctx.role, ctx.result)
155
+
156
+ check_step_failure(ctx) || check_cost_budget(ctx.state, ctx.logger)
157
+ end
158
+
159
+ def save_step_progress(ctx)
160
+ pipeline_state.save(ctx.issue_number,
161
+ completed_steps: ctx.state[:completed_steps],
162
+ worktree_path: ctx.chdir,
163
+ branch: current_branch(ctx.chdir, logger: ctx.logger))
164
+ end
165
+
166
+ def write_step_output(issue_number, idx, agent, output)
167
+ return if output.to_s.empty?
168
+
169
+ safe_agent = agent.to_s.gsub(/[^a-zA-Z0-9_-]/, '')
170
+ dir = File.join(@config.project_dir, '.ocak', 'logs', "issue-#{issue_number}")
171
+ FileUtils.mkdir_p(dir)
172
+ File.write(File.join(dir, "step-#{idx}-#{safe_agent}.md"), output)
173
+ rescue StandardError
174
+ nil # sidecar write failures must never crash the pipeline
175
+ end
176
+
177
+ def check_step_failure(ctx)
178
+ return nil if ctx.result.success? || !%w[implement merge].include?(ctx.role)
179
+
180
+ ctx.logger.error("#{ctx.role} failed")
181
+ { success: false, phase: ctx.role, output: ctx.result.output }
182
+ end
183
+
184
+ def check_cost_budget(state, logger)
185
+ return nil unless @config.cost_budget && state[:total_cost] > @config.cost_budget
186
+
187
+ cost = format('%.2f', state[:total_cost])
188
+ budget = format('%.2f', @config.cost_budget)
189
+ logger.error("Cost budget exceeded ($#{cost}/$#{budget})")
190
+ { success: false, phase: 'budget', output: "Cost budget exceeded: $#{cost}" }
191
+ end
192
+
193
+ def skip_reason(step, state)
194
+ condition = step[:condition]
195
+
196
+ return 'audit found blocking issues' if step[:role].to_s == 'merge' && @config.audit_mode && state[:audit_blocked]
197
+ return 'manual review mode' if step[:role].to_s == 'merge' && @config.manual_review
198
+ return 'fast-track issue (simple complexity)' if step[:complexity] == 'full' && state[:complexity] == 'simple'
199
+ if condition == 'has_findings' && !state[:last_review_output]&.include?("\u{1F534}")
200
+ return 'no blocking findings from review'
201
+ end
202
+ return 'no fixes were made' if condition == 'had_fixes' && !state[:had_fixes]
203
+
204
+ nil
205
+ end
206
+
207
+ def update_pipeline_state(role, result, state)
208
+ case role
209
+ when 'review', 'verify', 'security', 'audit'
210
+ state[:last_review_output] = result.output
211
+ if role == 'audit'
212
+ state[:audit_output] = result.output
213
+ state[:audit_blocked] = !result.success? || result.output.to_s.match?(/BLOCK|🔴/)
214
+ end
215
+ when 'fix'
216
+ state[:had_fixes] = true
217
+ state[:last_review_output] = nil
218
+ when 'implement'
219
+ state[:last_review_output] = nil
220
+ end
221
+ end
222
+
223
+ def run_final_verification(issue_number, logger:, claude:, chdir:)
224
+ return nil unless @config.test_command || @config.lint_check_command
225
+
226
+ logger.info('--- Final verification ---')
227
+ post_step_comment(issue_number, "\u{1F504} **Phase: final-verify** (verification)")
228
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
229
+ result = run_final_checks(logger, chdir: chdir)
230
+
231
+ unless result[:success]
232
+ logger.warn('Final checks failed, attempting fix...')
233
+ post_step_comment(issue_number, "\u{26A0}\u{FE0F} **Final verification failed** \u2014 attempting auto-fix...")
234
+ fix_prompt = "Fix these test/lint failures:\n\n" \
235
+ "<verification_output>\n#{result[:output]}\n</verification_output>"
236
+ claude.run_agent('implementer', fix_prompt, chdir: chdir)
237
+ result = run_final_checks(logger, chdir: chdir)
238
+ end
239
+
240
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
241
+ if result[:success]
242
+ post_step_comment(issue_number, "\u{2705} **Phase: final-verify** completed \u{2014} #{duration}s")
243
+ nil
244
+ else
245
+ post_step_comment(issue_number, "\u{274C} **Phase: final-verify** failed \u{2014} #{duration}s")
246
+ { success: false, phase: 'final-verify', output: result[:output] }
247
+ end
248
+ end
249
+
250
+ def log_cost_summary(total_cost, logger)
251
+ return if total_cost.zero?
252
+
253
+ budget = @config.cost_budget
254
+ budget_str = budget ? " / $#{format('%.2f', budget)} budget" : ''
255
+ logger.info("Pipeline cost: $#{format('%.4f', total_cost)}#{budget_str}")
256
+ end
257
+
258
+ def save_report(report, issue_number, success:, failed_phase: nil)
259
+ report.finish(success: success, failed_phase: failed_phase)
260
+ report.save(issue_number, project_dir: @config.project_dir)
261
+ rescue StandardError
262
+ nil # report save failures must never crash the pipeline
263
+ end
264
+
265
+ def pipeline_state
266
+ @pipeline_state ||= PipelineState.new(log_dir: File.join(@config.project_dir, @config.log_dir))
267
+ end
268
+
269
+ def current_branch(chdir, logger: nil)
270
+ stdout, = Open3.capture3('git', 'rev-parse', '--abbrev-ref', 'HEAD', chdir: chdir)
271
+ stdout.strip
272
+ rescue StandardError => e
273
+ logger&.debug("Could not determine current branch: #{e.message}")
274
+ nil
275
+ end
276
+
277
+ def post_pipeline_start_comment(issue_number, state)
278
+ total = @config.steps.size
279
+ conditional = conditional_step_count(state)
280
+ post_step_comment(issue_number,
281
+ "\u{1F680} **Pipeline started** \u2014 complexity: `#{state[:complexity]}` " \
282
+ "| steps: #{total} (#{conditional} may be skipped)")
283
+ end
284
+
285
+ def post_pipeline_summary_comment(issue_number, state, duration, success:, failed_phase: nil)
286
+ total = @config.steps.size
287
+ cost = format('%.2f', state[:total_cost])
288
+
289
+ if success
290
+ post_step_comment(issue_number,
291
+ "\u{2705} **Pipeline complete** \u2014 #{state[:steps_run]}/#{total} steps run " \
292
+ "| #{state[:steps_skipped]} skipped | $#{cost} total | #{duration}s")
293
+ else
294
+ post_step_comment(issue_number,
295
+ "\u{274C} **Pipeline failed** at phase: #{failed_phase} \u2014 " \
296
+ "#{state[:steps_run]}/#{total} steps completed | $#{cost} total")
297
+ end
298
+ end
299
+
300
+ def conditional_step_count(state)
301
+ @config.steps.count do |step|
302
+ step = symbolize(step)
303
+ step[:condition] ||
304
+ (step[:complexity] == 'full' && state[:complexity] == 'simple') ||
305
+ (step[:role].to_s == 'merge' && @config.manual_review) ||
306
+ (step[:role].to_s == 'merge' && @config.audit_mode) # merge may be skipped if audit finds blocking issues
307
+ end
308
+ end
309
+
310
+ def symbolize(hash)
311
+ return hash unless hash.is_a?(Hash)
312
+
313
+ hash.transform_keys(&:to_sym)
314
+ end
315
+ end
316
+ end