Class: Ace::Assign::Molecules::StepWriter

Inherits:
Object
  • Object
show all
Defined in:
lib/ace/assign/molecules/step_writer.rb

Overview

Writes and updates step markdown files.

Handles creation of new step files and updating existing ones, including appending reports and updating frontmatter.

Instance Method Summary collapse

Instance Method Details

#append_report(file_path, report_content, reports_dir:) ⇒ String

Append report content to step file

Parameters:

  • file_path (String)

    Path to step file

  • report_content (String)

    Report content to append

  • reports_dir (String)

    Path to reports directory

Returns:

  • (String)

    Updated file path



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/ace/assign/molecules/step_writer.rb', line 161

def append_report(file_path, report_content, reports_dir:)
  # Extract number and name from filename for report file
  filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))

  # Generate report filename
  report_filename = Atoms::StepFileParser.generate_report_filename(
    filename_info[:number],
    filename_info[:name]
  )
  report_path = File.join(reports_dir, report_filename)

  # Check if report file exists
  if File.exist?(report_path) && File.size(report_path) > 0
    # Append to existing report with file locking
    File.open(report_path, File::RDWR) do |f|
      f.flock(File::LOCK_EX)
      existing_content = f.read
      # Find the end of the frontmatter and append after it
      match = existing_content.match(/\n---\s*\n/)
      if match
        insertion_point = match.end(0)
        new_content = existing_content[0...insertion_point] + report_content + "\n" + existing_content[insertion_point..]
      else
        new_content = existing_content + "\n" + report_content
      end
      # Rewrite content in-place on locked file descriptor
      # This preserves the POSIX lock (rename would break it by replacing inode)
      f.rewind
      f.truncate(0)
      f.write(new_content)
      f.flush
      fsync_after_write(f)
    end
  else
    # Create new report file
    write_report(report_path, filename_info[:number], filename_info[:name], report_content)
  end

  file_path
end

#create(steps_dir:, number:, name:, instructions:, status: :pending, added_by: nil, parent: nil, extra: {}) ⇒ String

Create a new step file

Parameters:

  • steps_dir (String)

    Path to steps directory

  • number (String)

    Step number

  • name (String)

    Step name

  • instructions (String)

    Step instructions

  • status (Symbol) (defaults to: :pending)

    Initial status

  • added_by (String, nil) (defaults to: nil)

    How step was added

  • parent (String, nil) (defaults to: nil)

    Parent step number

Returns:

  • (String)

    Path to created file



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/ace/assign/molecules/step_writer.rb', line 24

def create(steps_dir:, number:, name:, instructions:, status: :pending,
  added_by: nil, parent: nil, extra: {})
  filename = Atoms::StepFileParser.generate_filename(number, name)
  file_path = File.join(steps_dir, filename)

  frontmatter = {
    "name" => name,
    "status" => status.to_s
  }
  frontmatter["added_by"] = added_by if added_by
  frontmatter["parent"] = parent if parent
  frontmatter.merge!(extra.transform_keys(&:to_s)) if extra&.any?

  content = build_file_content(frontmatter, instructions)
  atomic_write(file_path, content)

  file_path
end

#mark_done(file_path, report_content:, reports_dir:) ⇒ String

Mark step as done with report

Parameters:

  • file_path (String)

    Path to step file

  • report_content (String)

    Report content to write

  • reports_dir (String)

    Path to reports directory

Returns:

  • (String)

    Updated file path

Raises:

  • (ArgumentError)

    if report_content is nil or empty



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/ace/assign/molecules/step_writer.rb', line 94

def mark_done(file_path, report_content:, reports_dir:)
  # Validate report content
  raise ArgumentError, "Report content cannot be nil" if report_content.nil?
  raise ArgumentError, "Report content cannot be empty" if report_content.strip.empty?

  content = File.read(file_path)
  parsed = Atoms::StepFileParser.parse(content)

  # Extract number and name from filename for report file
  filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))

  # Update frontmatter only (status + completed_at)
  new_frontmatter = parsed[:frontmatter].merge({
    "status" => "done",
    "completed_at" => Time.now.utc.iso8601
  })

  # Write step file with updated frontmatter
  new_content = build_file_content(new_frontmatter, parsed[:body])
  atomic_write(file_path, new_content)

  # Write report to separate file
  report_filename = Atoms::StepFileParser.generate_report_filename(
    filename_info[:number],
    filename_info[:name]
  )
  report_path = File.join(reports_dir, report_filename)

  write_report(report_path, filename_info[:number], filename_info[:name], report_content)

  file_path
end

#mark_failed(file_path, error_message:) ⇒ String

Mark step as failed

Parameters:

  • file_path (String)

    Path to step file

  • error_message (String)

    Error message

Returns:

  • (String)

    Updated file path



132
133
134
135
136
137
138
# File 'lib/ace/assign/molecules/step_writer.rb', line 132

def mark_failed(file_path, error_message:)
  update_frontmatter(file_path, {
    "status" => "failed",
    "completed_at" => Time.now.utc.iso8601,
    "error" => error_message
  })
end

#mark_in_progress(file_path) ⇒ String

Mark step as in progress

Parameters:

  • file_path (String)

    Path to step file

Returns:

  • (String)

    Updated file path



66
67
68
69
70
71
# File 'lib/ace/assign/molecules/step_writer.rb', line 66

def mark_in_progress(file_path)
  update_frontmatter(file_path, {
    "status" => "in_progress",
    "started_at" => Time.now.utc.iso8601
  })
end

#mark_pending(file_path) ⇒ String

Mark step as pending again after it becomes blocked by newly added children.

Parameters:

  • file_path (String)

    Path to step file

Returns:

  • (String)

    Updated file path



77
78
79
80
81
82
83
84
85
# File 'lib/ace/assign/molecules/step_writer.rb', line 77

def mark_pending(file_path)
  update_frontmatter(file_path, {
    "status" => "pending",
    "started_at" => nil,
    "completed_at" => nil,
    "error" => nil,
    "stall_reason" => nil
  })
end

#record_fork_pid_info(file_path, launch_pid:, tracked_pids:, pid_file: nil) ⇒ String

Record fork execution PID metadata on a step.

Parameters:

  • file_path (String)

    Path to fork root step file

  • launch_pid (Integer)

    PID of launcher process

  • tracked_pids (Array<Integer>)

    Observed subprocess/descendant PIDs

Returns:

  • (String)

    Updated file path



146
147
148
149
150
151
152
153
# File 'lib/ace/assign/molecules/step_writer.rb', line 146

def record_fork_pid_info(file_path, launch_pid:, tracked_pids:, pid_file: nil)
  update_frontmatter(file_path, {
    "fork_launch_pid" => launch_pid.to_i,
    "fork_tracked_pids" => Array(tracked_pids).map(&:to_i).uniq.sort,
    "fork_pid_updated_at" => Time.now.utc.iso8601,
    "fork_pid_file" => pid_file
  })
end

#update_frontmatter(file_path, updates) ⇒ String

Update step frontmatter

Parameters:

  • file_path (String)

    Path to step file

  • updates (Hash)

    Frontmatter updates

Returns:

  • (String)

    Updated file path



48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/ace/assign/molecules/step_writer.rb', line 48

def update_frontmatter(file_path, updates)
  content = File.read(file_path)
  parsed = Atoms::StepFileParser.parse(content)

  # Merge updates into frontmatter
  new_frontmatter = parsed[:frontmatter].merge(updates.transform_keys(&:to_s))

  # Rebuild file
  new_content = build_file_content(new_frontmatter, parsed[:body])
  atomic_write(file_path, new_content)

  file_path
end