summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-11 08:54:10 +0300
committerPaul Buetow <paul@buetow.org>2026-05-11 08:54:10 +0300
commitd57d57fa6cf99db8447380202b7f091827728ef5 (patch)
tree6d317535f5e6d4487ef662330cd78a7a09dc0d14
parent8b0531bec5e9229ca41ab7bf143e319f66ed0a22 (diff)
project flow diagram
-rw-r--r--.serena/project.yml114
-rw-r--r--README.md56
-rw-r--r--docs/PROJECT_FLOW.md133
-rw-r--r--lib/dsl.rb2
-rw-r--r--lib/dslkeywords/agent.rb37
-rw-r--r--lib/dslkeywords/file.rb255
-rw-r--r--lib/dslkeywords/file_backup.rb12
-rw-r--r--lib/dslkeywords/prompt.rb39
-rw-r--r--test/lib/dslkeywords/agent_test.rb733
-rw-r--r--test/lib/dslkeywords/file_test.rb136
-rw-r--r--test/support/mock_agent.rb46
11 files changed, 1482 insertions, 81 deletions
diff --git a/.serena/project.yml b/.serena/project.yml
index 5b72e88..392a540 100644
--- a/.serena/project.yml
+++ b/.serena/project.yml
@@ -3,21 +3,26 @@ project_name: "rcm"
# list of languages for which language servers are started; choose from:
-# al bash clojure cpp csharp
-# csharp_omnisharp dart elixir elm erlang
-# fortran fsharp go groovy haskell
-# java julia kotlin lua markdown
-# matlab nix pascal perl php
-# php_phpactor powershell python python_jedi r
-# rego ruby ruby_solargraph rust scala
-# swift terraform toml typescript typescript_vts
-# vue yaml zig
+# al angular ansible bash clojure
+# cpp cpp_ccls crystal csharp csharp_omnisharp
+# dart elixir elm erlang fortran
+# fsharp go groovy haskell haxe
+# hlsl html java json julia
+# kotlin lean4 lua luau markdown
+# matlab msl nix ocaml pascal
+# perl php php_phpactor powershell python
+# python_jedi python_ty r rego ruby
+# ruby_solargraph rust scala scss solidity
+# swift systemverilog terraform toml typescript
+# typescript_vts vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
+# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
+# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
@@ -52,52 +57,19 @@ ignored_paths: []
# Added on 2025-04-18
read_only: false
-# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
-# Below is the complete list of tools for convenience.
-# To make sure you have the latest list of tools, and to view their descriptions,
-# execute `uv run scripts/print_tool_overview.py`.
-#
-# * `activate_project`: Activates a project by name.
-# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
-# * `create_text_file`: Creates/overwrites a file in the project directory.
-# * `delete_lines`: Deletes a range of lines within a file.
-# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
-# * `execute_shell_command`: Executes a shell command.
-# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
-# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
-# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
-# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
-# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
-# * `initial_instructions`: Gets the initial instructions for the current project.
-# Should only be used in settings where the system prompt cannot be set,
-# e.g. in clients you have no control over, like Claude Desktop.
-# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
-# * `insert_at_line`: Inserts content at a given line in a file.
-# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
-# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
-# * `list_memories`: Lists memories in Serena's project-specific memory store.
-# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
-# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
-# * `read_file`: Reads a file within the project directory.
-# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
-# * `remove_project`: Removes a project from the Serena configuration.
-# * `replace_lines`: Replaces a range of lines within a file with new content.
-# * `replace_symbol_body`: Replaces the full definition of a symbol.
-# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
-# * `search_for_pattern`: Performs a search for a pattern in the project.
-# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
-# * `switch_modes`: Activates modes by providing a list of their names
-# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
-# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
-# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
-# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
+# list of tool names to exclude.
+# This extends the existing exclusions (e.g. from the global configuration)
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
-# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
+# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
+# This extends the existing inclusions (e.g. from the global configuration).
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
@@ -108,11 +80,14 @@ fixed_tools: []
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
-# list of mode names that are to be activated by default.
-# The full set of modes to be activated is base_modes + default_modes.
-# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
+# list of mode names that are to be activated by default, overriding the setting in the global configuration.
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
+# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
@@ -128,3 +103,38 @@ symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as read‑only.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
+
+# line ending convention to use when writing source files.
+# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
+# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
+line_ending:
+
+# list of regex patterns for memories to completely ignore.
+# Matching memories will not appear in list_memories or activate_project output
+# and cannot be accessed via read_memory or write_memory.
+# To access ignored memory files, use the read_file tool on the raw file path.
+# Extends the list from the global configuration, merging the two lists.
+# Example: ["_archive/.*", "_episodes/.*"]
+ignored_memory_patterns: []
+
+# advanced configuration option allowing to configure language server-specific options.
+# Maps the language key to the options.
+# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
+# No documentation on options means no options are available.
+ls_specific_settings: {}
+
+# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
+added_modes:
+
+# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
+# Paths can be absolute or relative to the project root.
+# Each folder is registered as an LSP workspace folder, enabling language servers to discover
+# symbols and references across package boundaries.
+# Currently supported for: TypeScript.
+# Example:
+# additional_workspace_folders:
+# - ../sibling-package
+# - ../shared-lib
+additional_workspace_folders: []
diff --git a/README.md b/README.md
index 05562a0..f2cb84c 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ This software has been written by a human by 90%, and only the last 10% were AI
- [DSL Reference](#dsl-reference)
- [configure / configure_from_scratch](#configure--configure_from_scratch)
- [file](#file)
+ - [agent / prompt / command](#agent--prompt--command)
- [touch](#touch)
- [symlink](#symlink)
- [directory](#directory)
@@ -201,6 +202,16 @@ file '/tmp/deep/nested/dir/config.txt' do
'content'
end
+# File content extended from a command
+command spell do
+ 'aspell list < FILE_PATH'
+end
+
+file '/tmp/post.txt' do
+ append from command spell
+ 'Draft text'
+end
+
# Named file with explicit path
file create config do
path '/etc/myapp.conf'
@@ -215,6 +226,51 @@ file '/tmp/obsolete.txt' do
end
```
+### agent / prompt / command
+
+Use `agent` definitions for the final file-processing command, `command`
+definitions for reusable prompt-time shell commands, and `prompt`
+definitions to compose the text passed to the agent.
+
+```ruby
+agent hexai do
+ retries 3
+ retry_delay 1
+ retry_backoff 2
+ 'hexai PROMPT'
+end
+
+command spell do
+ 'aspell list < FILE_PATH'
+end
+
+prompt fix english do
+ append from command spell
+ 'Correct spelling and grammar. Use the spell-check output as hints.'
+end
+
+file '/tmp/post.txt' do
+ agent hexai fix english
+end
+```
+
+Inside a prompt block, `append from command ...` and `prepend from command ...`
+insert the raw stdout of the named command after or before the static prompt
+text. `FILE_PATH` expands to the current file being processed.
+
+Agent-backed files are cached under `$XDG_CACHE_HOME/rcm/agents` (or
+`~/.cache/rcm/agents` when `XDG_CACHE_HOME` is unset). RCM reruns the
+agent only when the file content checksum changes or the selected
+agent, prompt, or prompt-command definition changes.
+
+Agent definitions retry failed invocations by default twice after the
+first run, for 3 total attempts. If the agent still fails, RCM skips
+that file and continues with the next resource. `retries` sets the
+number of extra attempts after the first run, `retry_delay` sets the
+initial wait in seconds, and `retry_backoff` multiplies that delay after
+each failure. This is useful for transient failures from remote-backed
+agents such as network timeouts.
+
### touch
Create empty files, like the Unix `touch` command.
diff --git a/docs/PROJECT_FLOW.md b/docs/PROJECT_FLOW.md
new file mode 100644
index 0000000..f8f0146
--- /dev/null
+++ b/docs/PROJECT_FLOW.md
@@ -0,0 +1,133 @@
+# RCM Project Flow
+
+This diagram shows how a configuration run moves from the entry point through parsing, registration, dependency resolution, and finally execution.
+
+## High-Level Flow
+
+```mermaid
+flowchart TD
+ subgraph Entry["Entry Point"]
+ A[configure / configure_from_scratch] --> B{reset?}
+ B -->|yes| C[DSL.reset!]
+ B -->|no| D[DSL.new]
+ end
+
+ subgraph Init["Bootstrap"]
+ D --> E[Options.parse!]
+ E --> F[Config.load!]
+ F --> G[DSL instance_eval &block]
+ end
+
+ subgraph Register["Registration"]
+ G --> H[file / package / touch / ...]
+ H --> I{conds_met?}
+ I -->|yes| J[Keyword.new]
+ I -->|no| Z1[skip]
+ J --> K[register obj in @@objs]
+ K --> L{is Resource?}
+ L -->|yes| M[add to @scheduled]
+ L -->|no| Z2[done]
+ end
+
+ subgraph Eval["Evaluation"]
+ N[evaluate!] --> O[foreach scheduled]
+ O --> P[Resource.evaluate!]
+ P --> Q{already evaluated?}
+ Q -->|yes| Z3[skip]
+ Q -->|no| R[loop detection check]
+ R --> S[resolve dependencies]
+ S --> T[run action / dry-run?]
+ T --> U[mark evaluated]
+ end
+
+ G -.-> N
+```
+
+## Class & Mixin Relationships
+
+```mermaid
+classDiagram
+ class Keyword {
+ +id
+ +dsl
+ +id_for(name)
+ }
+
+ class Resource {
+ +evaluated
+ +subclass_names
+ +find(id)
+ +evaluate!()
+ }
+
+ class DSL {
+ +conds_met
+ +register(obj)
+ +object!(klass, name)
+ +evaluate!()
+ +register_keyword(...)
+ }
+
+ class Config {
+ +load!()
+ +config(key)
+ }
+
+ class Options {
+ +parse!()
+ +option(key)
+ }
+
+ class Log {
+ +info(msg)
+ +debug(msg)
+ +warn(msg)
+ }
+
+ class Chained {
+ +method_missing()
+ }
+
+ class DryRun {
+ +do?(message)
+ }
+
+ class ResourceDependencies {
+ +requires(*others)
+ +requires?(*others)
+ }
+
+ class DependencyEvaluator {
+ +evaluate!()
+ }
+
+ Keyword <|-- Resource
+ DSL --> Keyword : registers
+ DSL --> Resource : schedules
+
+ Keyword ..> Log : includes
+ Keyword ..> Options : includes
+ DSL ..> Config : includes
+ DSL ..> Options : includes
+ DSL ..> Log : includes
+ DSL ..> Chained : includes
+
+ Resource ..> DryRun : includes
+ Resource ..> ResourceDependencies : includes
+ Resource ..> DependencyEvaluator : includes
+```
+
+## What Happens During a Run
+
+1. **Entry** — `configure(reset: true)` or `configure(reset: false)` is called.
+2. **Bootstrap** — `Options.parse!` reads CLI flags (`--debug`, `--dry`, `--hosts`) and `Config.load!` reads `config.toml`.
+3. **DSL block** — The user’s configuration block is executed inside the DSL instance via `instance_eval`.
+4. **Keyword creation** — Each keyword (`file`, `given`, `notify`, etc.) instantiates a `Keyword` or `Resource` subclass. Names are normalised via `Keyword.id_for`.
+5. **Registration** — The generic `register(obj)` stores the object in the class-level `@@objs` hash keyed by `id`. If the object is a `Resource`, it is also appended to `@scheduled`.
+6. **Conditionals** — `given { ... }` sets `@conds_met`. When false, subsequent keyword calls are skipped (no object created).
+7. **Evaluation** — After the block finishes, `DSL#evaluate!` iterates `@scheduled`. Each `Resource#evaluate!`:
+ - Checks for dependency loops.
+ - Recursively evaluates its `requires` dependencies.
+ - Executes the concrete action (file write, package install, etc.) unless `--dry` is active.
+ - Marks itself as evaluated.
+8. **Dry-run** — The `DryRun#do?` mixin wraps every side-effecting action. In dry mode it logs and returns without touching the system.
diff --git a/lib/dsl.rb b/lib/dsl.rb
index 0414050..ff147f3 100644
--- a/lib/dsl.rb
+++ b/lib/dsl.rb
@@ -7,6 +7,7 @@ require_relative 'log'
require_relative 'chained'
require_relative 'dslkeywords/agent'
+require_relative 'dslkeywords/command'
require_relative 'dslkeywords/prompt'
require_relative 'dslkeywords/file'
require_relative 'dslkeywords/symlink'
@@ -38,6 +39,7 @@ module RCM
class DuplicateResource < StandardError; end
class DuplicateDefinition < StandardError; end
class NoSuchAgentDefinition < StandardError; end
+ class NoSuchCommandDefinition < StandardError; end
class NoSuchPromptDefinition < StandardError; end
def initialize(reset)
diff --git a/lib/dslkeywords/agent.rb b/lib/dslkeywords/agent.rb
index 6632835..3d6874c 100644
--- a/lib/dslkeywords/agent.rb
+++ b/lib/dslkeywords/agent.rb
@@ -8,6 +8,7 @@ module RCM
attr_reader :name
class InvalidName < StandardError; end
+ class InvalidRetrySetting < StandardError; end
def self.id_for(name) = super(normalize_name(name))
@@ -20,6 +21,9 @@ module RCM
def initialize(name)
@name = self.class.normalize_name(name)
+ @retries = 2
+ @retry_delay = 1.0
+ @retry_backoff = 2.0
super(@name)
end
@@ -28,6 +32,39 @@ module RCM
@command = text.to_s
end
+
+ def retries(value = nil)
+ return @retries if value.nil?
+
+ @retries = Integer(value)
+ raise InvalidRetrySetting, 'Retry count must be non-negative' if @retries.negative?
+
+ @retries
+ rescue ArgumentError, TypeError
+ raise InvalidRetrySetting, "Invalid retry count: #{value.inspect}"
+ end
+
+ def retry_delay(value = nil)
+ return @retry_delay if value.nil?
+
+ @retry_delay = Float(value)
+ raise InvalidRetrySetting, 'Retry delay must be non-negative' if @retry_delay.negative?
+
+ @retry_delay
+ rescue ArgumentError, TypeError
+ raise InvalidRetrySetting, "Invalid retry delay: #{value.inspect}"
+ end
+
+ def retry_backoff(value = nil)
+ return @retry_backoff if value.nil?
+
+ @retry_backoff = Float(value)
+ raise InvalidRetrySetting, 'Retry backoff must be at least 1.0' if @retry_backoff < 1.0
+
+ @retry_backoff
+ rescue ArgumentError, TypeError
+ raise InvalidRetrySetting, "Invalid retry backoff: #{value.inspect}"
+ end
end
# Adds the `agent` definition keyword to the top-level DSL.
diff --git a/lib/dslkeywords/file.rb b/lib/dslkeywords/file.rb
index 1e40691..06bb5ce 100644
--- a/lib/dslkeywords/file.rb
+++ b/lib/dslkeywords/file.rb
@@ -3,12 +3,14 @@
require 'digest'
require 'erb'
require 'fileutils'
+require 'json'
require 'open3'
require 'shellwords'
require 'tempfile'
require_relative 'resource'
require_relative '../chained'
+require_relative 'command'
require_relative 'file_backup'
module RCM
@@ -131,7 +133,11 @@ module RCM
# sourcefile reading. Touch and Directory extend BasicFile directly so
# they are not burdened with content/from (ISP).
class BaseFile < BasicFile
- def from(what) = @from = validate(__method__, what.to_sym, :sourcefile, :template)
+ def from(what)
+ return what if what.is_a?(CommandDefinition::Reference)
+
+ @from = validate(__method__, what.to_sym, :sourcefile, :template)
+ end
# Return or set the resource's content.
# Getter: resolves ERB templates or reads sourcefile on demand.
@@ -149,12 +155,22 @@ module RCM
# delete. Writes via a temp file so the final rename is atomic.
# rubocop:disable Metrics/ClassLength
class File < BaseFile
+ include CommandComposable
+
class AgentCommandFailed < StandardError; end
+ class CommandFailed < StandardError; end
+ class InvalidComposition < StandardError; end
class InvalidAgentSpec < StandardError; end
+ class InvalidAgentCacheRecord < StandardError; end
class MissingAgentInput < StandardError; end
attr_reader :agent_name, :prompt_name
+ def initialize(file_path)
+ super(file_path)
+ initialize_command_composition!
+ end
+
def agent(spec = nil, prompt_name = nil)
agent_name, prompt_name = resolved_agent_spec(spec, prompt_name)
@@ -170,6 +186,12 @@ module RCM
def line(line) = @ensure_line = line
+ def content(text = nil)
+ return render_composed_content(super(), @file_path) if text.nil?
+
+ super
+ end
+
def evaluate!
return unless super
@@ -254,16 +276,23 @@ module RCM
raise MissingAgentInput, "File #{@file_path} does not exist for agent processing" unless ::File.file?(@file_path)
agent_definition, prompt_definition = agent_configuration!
+ cache_state = agent_cache_state(agent_definition, prompt_definition)
+ return skip_fresh_agent_processing! unless cache_state[:stale]
- if option :dry
- info "Processing #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name} - dry run!"
- return
- end
+ info "Agent cache is stale for #{@file_path}: #{cache_state[:reason]}"
+ process_stale_agent!(agent_definition, prompt_definition, cache_state)
+ rescue AgentCommandFailed => e
+ warn "#{e.message}. Skipping #{@file_path} and continuing"
+ end
+ def process_stale_agent!(agent_definition, prompt_definition, cache_state)
input = ::File.read(@file_path)
output = run_agent!(input, agent_definition, prompt_definition)
+ return if option :dry
+
create_parent_directory! unless ::File.directory?(::File.dirname(@file_path))
write!(output)
+ refresh_agent_cache_record!(cache_state[:record_path], cache_state[:definition_fingerprint])
end
# rubocop:disable Metrics/MethodLength
@@ -294,25 +323,121 @@ module RCM
end
# rubocop:enable Metrics/MethodLength
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def run_agent!(input, agent_definition, prompt_definition)
+ with_agent_input_file(input) do |input_path|
+ prompt_text = prompt_definition.render(@file_path)
+ return dry_run_agent_input(input) if option :dry
+
+ command = render_agent_command(agent_definition.command.to_s, prompt_text, input_path)
+ execute_agent_command_with_retries(command, input, agent_definition)
+ end
+ end
+
+ def execute_agent_command_with_retries(command, input, agent_definition)
+ attempt = 1
+ retry_state = agent_retry_state(agent_definition)
+
+ loop do
+ stdout, stderr, status = capture_agent_command(command, input, attempt, retry_state[:max_attempts])
+ return stdout if status.success?
+
+ retry_state[:delay] = handle_failed_agent_command(status, stderr, attempt, retry_state)
+ attempt += 1
+ end
+ end
+
+ def agent_retry_state(agent_definition)
+ {
+ max_attempts: agent_definition.retries + 1,
+ delay: agent_definition.retry_delay,
+ backoff: agent_definition.retry_backoff
+ }
+ end
+
+ def handle_failed_agent_command(status, stderr, attempt, retry_state)
+ failure_message = agent_command_failure_message(status, stderr, attempt, retry_state[:max_attempts])
+ retry_agent_command!(
+ failure_message,
+ attempt,
+ retry_state[:max_attempts],
+ retry_state[:delay],
+ retry_state[:backoff]
+ )
+ end
+
+ def with_agent_input_file(input)
Tempfile.create(['rcm-agent-input', '.txt']) do |tmp|
tmp.write(input)
tmp.flush
tmp.close
+ yield tmp.path
+ end
+ end
- command = render_agent_command(agent_definition.command.to_s, prompt_definition.text.to_s, tmp.path)
- info "Processing #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name}"
- stdout, stderr, status = Open3.capture3(command, stdin_data: input)
- return stdout if status.success?
+ def dry_run_agent_input(input)
+ info "Processing #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name} - dry run!"
+ input
+ end
+
+ def capture_agent_command(command, input, attempt, max_attempts)
+ info agent_processing_message(attempt, max_attempts)
+ Open3.popen3(command) do |stdin, stdout, stderr, wait_thread|
+ stdin.write(input)
+ stdin.close
+
+ stdout_buffer, stderr_buffer, stream_threads = start_agent_streams(stdout, stderr)
+ stream_threads.each(&:join)
+
+ [stdout_buffer, stderr_buffer, wait_thread.value]
+ end
+ end
+
+ def retry_agent_command!(failure_message, attempt, max_attempts, retry_delay, retry_backoff)
+ raise AgentCommandFailed, failure_message if attempt >= max_attempts
- message = stderr.to_s.strip
- message = 'no stderr output' if message.empty?
- raise AgentCommandFailed,
- "Agent #{@agent_name} failed for #{@file_path} (exit #{status.exitstatus}): #{message}"
+ warn "#{failure_message}. Retrying in #{formatted_retry_delay(retry_delay)}s"
+ sleep(retry_delay) if retry_delay.positive?
+ retry_delay * retry_backoff
+ end
+
+ def stream_agent_io(source, buffer, destination)
+ loop do
+ chunk = source.readpartial(4096)
+ buffer << chunk
+ destination.write(chunk)
+ destination.flush
+ rescue EOFError
+ break
end
+
+ destination.puts unless buffer.empty? || buffer.end_with?("\n")
+ end
+
+ def start_agent_streams(stdout, stderr)
+ stdout_buffer = +''
+ stderr_buffer = +''
+ stream_threads = [
+ Thread.new { stream_agent_io(stdout, stdout_buffer, $stdout) },
+ Thread.new { stream_agent_io(stderr, stderr_buffer, $stderr) }
+ ]
+ [stdout_buffer, stderr_buffer, stream_threads]
+ end
+
+ def agent_processing_message(attempt, max_attempts)
+ suffix = max_attempts > 1 ? " (attempt #{attempt}/#{max_attempts})" : ''
+ "Processing #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name}#{suffix}"
+ end
+
+ def agent_command_failure_message(status, stderr, attempt, max_attempts)
+ message = stderr.to_s.strip
+ message = 'no stderr output' if message.empty?
+ attempt_suffix = max_attempts > 1 ? ", attempt #{attempt}/#{max_attempts}" : ''
+ "Agent #{@agent_name} failed for #{@file_path} (exit #{status.exitstatus}#{attempt_suffix}): #{message}"
+ end
+
+ def formatted_retry_delay(retry_delay)
+ format('%.3g', retry_delay)
end
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
def agent_configuration!
[
@@ -328,6 +453,106 @@ module RCM
command.gsub!(/\bFILE_PATH\b/, Shellwords.escape(@file_path))
command
end
+
+ def agent_cache_state(agent_definition, prompt_definition)
+ file_checksum = checksum_for(@file_path)
+ definition_fingerprint = agent_definition_fingerprint(agent_definition, prompt_definition)
+ record_path = agent_cache_record_path
+ stale, reason = stale_agent_cache_record?(
+ load_agent_cache_record(record_path),
+ file_checksum,
+ definition_fingerprint
+ )
+
+ { stale:, reason:, record_path:, definition_fingerprint: }
+ end
+
+ def stale_agent_cache_record?(record, file_checksum, definition_fingerprint)
+ return [true, 'no track record'] if record.nil?
+ return [true, 'cache format changed'] if record['version'] != 2 || record['file_checksum'].nil?
+ return [true, 'file checksum changed'] if record['file_checksum'] != file_checksum
+ return [true, 'agent or prompt changed'] if record['definition_fingerprint'] != definition_fingerprint
+
+ [false, 'fresh']
+ end
+
+ def agent_definition_fingerprint(agent_definition, prompt_definition)
+ Digest::SHA256.hexdigest(
+ JSON.generate(
+ agent_command: agent_definition.command.to_s,
+ prompt: prompt_definition.fingerprint_source
+ )
+ )
+ end
+
+ def refresh_agent_cache_record!(record_path, definition_fingerprint)
+ persist_agent_cache_record!(
+ record_path,
+ agent_cache_record_payload(checksum_for(@file_path), definition_fingerprint)
+ )
+ end
+
+ def agent_cache_record_payload(file_checksum, definition_fingerprint)
+ {
+ version: 2,
+ file_path: expanded_file_path,
+ agent_name: @agent_name,
+ prompt_name: @prompt_name,
+ file_checksum:,
+ definition_fingerprint:,
+ recorded_at_ns: current_time_ns
+ }
+ end
+
+ def persist_agent_cache_record!(record_path, payload)
+ cache_dir = ::File.dirname(record_path)
+ ::FileUtils.mkdir_p(cache_dir)
+ tmp_path = "#{record_path}.tmp"
+ ::File.write(tmp_path, JSON.generate(payload))
+ ::File.rename(tmp_path, record_path)
+ rescue SystemCallError => e
+ warn "Unable to persist agent cache #{record_path}: #{e.message}"
+ ::File.delete(tmp_path) if defined?(tmp_path) && ::File.file?(tmp_path)
+ end
+
+ def load_agent_cache_record(record_path)
+ return unless ::File.file?(record_path)
+
+ JSON.parse(::File.read(record_path))
+ rescue JSON::ParserError, SystemCallError => e
+ warn "Ignoring invalid agent cache #{record_path}: #{e.message}"
+ nil
+ end
+
+ def agent_cache_record_path
+ key = Digest::SHA256.hexdigest(
+ JSON.generate(file_path: expanded_file_path, agent_name: @agent_name, prompt_name: @prompt_name)
+ )
+ ::File.join(agent_cache_dir, "#{key}.json")
+ end
+
+ def agent_cache_dir
+ cache_root = ENV['XDG_CACHE_HOME'] || ::File.expand_path('~/.cache')
+ ::File.join(cache_root, 'rcm', 'agents')
+ end
+
+ def expanded_file_path = ::File.expand_path(@file_path)
+
+ def current_time_ns
+ now = ::Time.now
+ (now.to_i * 1_000_000_000) + now.nsec
+ end
+
+ def skip_fresh_agent_processing!
+ info "Skipping #{@file_path} with agent #{@agent_name} and prompt #{@prompt_name}; agent cache is fresh"
+ end
+
+ def invalid_command_composition!(position)
+ raise InvalidComposition, "#{position} expects `from command ...`"
+ end
+
+ def command_failure_class = CommandFailed
+ def command_composition_subject = "file #{@file_path}"
end
# rubocop:enable Metrics/ClassLength
diff --git a/lib/dslkeywords/file_backup.rb b/lib/dslkeywords/file_backup.rb
index 210804c..1716aa5 100644
--- a/lib/dslkeywords/file_backup.rb
+++ b/lib/dslkeywords/file_backup.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'digest'
module RCM
@@ -5,12 +7,16 @@ module RCM
# Included by BasicFile so all file/directory/symlink resources share
# the same backup logic.
module FileBackup
+ def checksum_for(file_path)
+ Digest::SHA256.file(file_path).hexdigest
+ end
+
# TODO: Make protected?
def backup!(file_path, checksum = nil)
return if @without_backup
suffix = if ::File.file?(file_path)
- checksum.nil? ? Digest::SHA256.file(file_path).hexdigest : checksum
+ checksum.nil? ? checksum_for(file_path) : checksum
else
Time.now.strftime('%s-%L')
end
@@ -18,8 +24,8 @@ module RCM
end
def different?(file_a, file_b)
- checksum_a = Digest::SHA256.file(file_a).hexdigest
- checksum_b = Digest::SHA256.file(file_b).hexdigest
+ checksum_a = checksum_for(file_a)
+ checksum_b = checksum_for(file_b)
[checksum_a != checksum_b, checksum_a, checksum_b]
end
diff --git a/lib/dslkeywords/prompt.rb b/lib/dslkeywords/prompt.rb
index 9e599ef..1b383b9 100644
--- a/lib/dslkeywords/prompt.rb
+++ b/lib/dslkeywords/prompt.rb
@@ -1,13 +1,20 @@
# frozen_string_literal: true
+require_relative '../chained'
+require_relative 'command'
require_relative 'keyword'
module RCM
# Stores a named prompt body for agent-backed file processing.
class PromptDefinition < Keyword
+ include Chained
+ include CommandComposable
+
attr_reader :name
class InvalidName < StandardError; end
+ class InvalidComposition < StandardError; end
+ class CommandFailed < StandardError; end
def self.id_for(name) = super(normalize_name(name))
@@ -20,6 +27,7 @@ module RCM
def initialize(name)
@name = self.class.normalize_name(name)
+ initialize_command_composition!
super(@name)
end
@@ -28,6 +36,37 @@ module RCM
@text = value.to_s
end
+
+ def from(value = nil) = value
+
+ def render(file_path) = render_composed_content(text.to_s, file_path)
+
+ def fingerprint_source
+ {
+ text: text.to_s,
+ composition: @command_composition.map { |entry| fingerprinted_command(entry) }
+ }
+ end
+
+ private
+
+ def fingerprinted_command(entry)
+ command_definition = dsl.object!(
+ CommandDefinition,
+ entry[:name],
+ error_class: DSL::NoSuchCommandDefinition,
+ kind: 'command'
+ )
+
+ entry.merge(template: command_definition.template.to_s)
+ end
+
+ def invalid_command_composition!(position)
+ raise InvalidComposition, "#{position} expects `from command ...`"
+ end
+
+ def command_failure_class = CommandFailed
+ def command_composition_subject = "prompt #{@name}"
end
# Adds the `prompt` definition keyword to the top-level DSL.
diff --git a/test/lib/dslkeywords/agent_test.rb b/test/lib/dslkeywords/agent_test.rb
index b09a2ec..39d997e 100644
--- a/test/lib/dslkeywords/agent_test.rb
+++ b/test/lib/dslkeywords/agent_test.rb
@@ -2,6 +2,7 @@
# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
require 'minitest/autorun'
+require 'json'
require 'fileutils'
require 'rbconfig'
require 'shellwords'
@@ -15,10 +16,13 @@ class RCMAgentTest < Minitest::Test
def setup
@dir_path = Dir.mktmpdir('.agent_test.rcmtmp.')
@original_argv = ARGV.dup
+ @original_xdg_cache_home = ENV['XDG_CACHE_HOME']
+ ENV['XDG_CACHE_HOME'] = path('cache')
end
def teardown
ARGV.replace(@original_argv) if @original_argv
+ ENV['XDG_CACHE_HOME'] = @original_xdg_cache_home
FileUtils.rm_rf(@dir_path) if @dir_path
end
@@ -39,6 +43,14 @@ class RCMAgentTest < Minitest::Test
[Shellwords.escape(parts.shift), Shellwords.escape(parts.shift), Shellwords.escape(parts.shift), *parts].join(' ')
end
+ def agent_cache_files
+ Dir.glob(File.join(ENV.fetch('XDG_CACHE_HOME'), 'rcm', 'agents', '*.json'))
+ end
+
+ def counter_value(counter_path)
+ File.file?(counter_path) ? File.read(counter_path).to_i : 0
+ end
+
def test_duplicate_agent_definition
assert_raises(RCM::DSL::DuplicateDefinition) do
configure_from_scratch do
@@ -67,6 +79,22 @@ class RCMAgentTest < Minitest::Test
end
end
+ def test_duplicate_command_definition
+ template = mock_agent_command(:join_args, 'spell')
+
+ assert_raises(RCM::DSL::DuplicateDefinition) do
+ configure_from_scratch do
+ command spell do
+ template
+ end
+
+ command spell do
+ template
+ end
+ end
+ end
+ end
+
def test_agent_processes_file_using_stdin_and_names_with_spaces
file_path = path('process.txt')
command = mock_agent_command(:upcase_prompt, 'PROMPT')
@@ -111,6 +139,89 @@ class RCMAgentTest < Minitest::Test
assert_equal 'HELLO WORLD|Fix grammar', File.read(file_path)
end
+ def test_agent_processes_file_using_prompt_appended_command_output
+ file_path = path('process-appended-command.txt')
+ agent_command = mock_agent_command(:upcase_prompt, 'PROMPT')
+ spell_command = mock_agent_command(:join_args, 'spell', 'FILE_PATH')
+ File.write(file_path, 'hello world')
+
+ configure_from_scratch do
+ agent mock do
+ agent_command
+ end
+
+ command spell output do
+ spell_command
+ end
+
+ prompt fix english do
+ append from command spell output
+ 'Fix grammar'
+ end
+
+ file file_path do
+ agent mock fix english
+ end
+ end
+
+ assert_equal "HELLO WORLD|Fix grammar\nspell|#{file_path}", File.read(file_path)
+ end
+
+ def test_agent_processes_file_using_prompt_prepended_command_output
+ file_path = path('process-prepended-command.txt')
+ agent_command = mock_agent_command(:upcase_prompt, 'PROMPT')
+ spell_command = mock_agent_command(:join_args, 'spell', 'FILE_PATH')
+ File.write(file_path, 'hello world')
+
+ configure_from_scratch do
+ agent mock do
+ agent_command
+ end
+
+ command spell output do
+ spell_command
+ end
+
+ prompt fix english do
+ prepend from command spell output
+ 'Fix grammar'
+ end
+
+ file file_path do
+ agent mock fix english
+ end
+ end
+
+ assert_equal "HELLO WORLD|spell|#{file_path}\nFix grammar", File.read(file_path)
+ end
+
+ def test_agent_streams_output_while_capturing_final_content
+ file_path = path('streamed-output.txt')
+ command = mock_agent_command(:stream_chunks, 'he', 'llo', '--stderr=oops')
+ File.write(file_path, 'ignored')
+
+ stdout, stderr = capture_io do
+ configure_from_scratch do
+ agent streamer do
+ retries 0
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent streamer, 'no op'
+ end
+ end
+ end
+
+ assert_includes stdout, 'hello'
+ assert_includes stderr, 'oops'
+ assert_equal 'hello', File.read(file_path)
+ end
+
def test_agent_can_use_input_placeholder
file_path = path('input.txt')
command = mock_agent_command(:reverse_input, 'INPUT')
@@ -233,6 +344,398 @@ class RCMAgentTest < Minitest::Test
assert_equal 1, Dir.glob(File.join(backup_dir, 'backup.txt.*')).count
end
+ def test_agent_processing_creates_track_record_and_skips_when_checksum_is_fresh
+ file_path = path('fresh.txt')
+ counter_path = path('fresh-counter.txt')
+ command = mock_agent_command(:count_pass_through, counter_path)
+ File.write(file_path, 'same content')
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ assert_equal 1, counter_value(counter_path)
+ assert_equal 1, agent_cache_files.count
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ assert_equal 1, counter_value(counter_path)
+ assert_equal 1, agent_cache_files.count
+ record = JSON.parse(File.read(agent_cache_files.first))
+ assert_equal Digest::SHA256.hexdigest('same content'), record.fetch('file_checksum')
+ end
+
+ def test_agent_processing_updates_track_record_immediately_after_file_change
+ file_path = path('fresh-after-change.txt')
+ counter_path = path('fresh-after-change-counter.txt')
+ command = mock_agent_command(:count_upcase, counter_path)
+ File.write(file_path, 'same content')
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ assert_equal 'SAME CONTENT', File.read(file_path)
+ assert_equal 1, counter_value(counter_path)
+ record = JSON.parse(File.read(agent_cache_files.first))
+ assert_equal Digest::SHA256.hexdigest('SAME CONTENT'), record.fetch('file_checksum')
+ end
+
+ def test_agent_processing_reruns_when_input_checksum_changes
+ file_path = path('changed.txt')
+ counter_path = path('changed-counter.txt')
+ command = mock_agent_command(:count_pass_through, counter_path)
+ File.write(file_path, 'same content')
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ File.write(file_path, 'changed content')
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ assert_equal 2, counter_value(counter_path)
+ end
+
+ def test_agent_processing_reruns_when_agent_definition_changes
+ file_path = path('agent-definition-change.txt')
+ counter_path = path('agent-definition-change-counter.txt')
+ command = mock_agent_command(:count_pass_through, counter_path)
+ File.write(file_path, 'same content')
+
+ configure_from_scratch do
+ agent 'count runs' do
+ "#{command} # one"
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ configure_from_scratch do
+ agent 'count runs' do
+ "#{command} # two"
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ assert_equal 2, counter_value(counter_path)
+ end
+
+ def test_agent_processing_reruns_when_prompt_definition_changes
+ file_path = path('prompt-definition-change.txt')
+ counter_path = path('prompt-definition-change-counter.txt')
+ command = mock_agent_command(:count_pass_through, counter_path)
+ File.write(file_path, 'same content')
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt 'no op' do
+ 'changed prompt'
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ assert_equal 2, counter_value(counter_path)
+ end
+
+ def test_agent_processing_reruns_when_prompt_command_definition_changes
+ file_path = path('prompt-command-definition-change.txt')
+ counter_path = path('prompt-command-definition-change-counter.txt')
+ agent_command = mock_agent_command(:count_pass_through, counter_path)
+ spell_command = mock_agent_command(:join_args, 'spell', 'FILE_PATH')
+ File.write(file_path, 'same content')
+
+ configure_from_scratch do
+ agent 'count runs' do
+ agent_command
+ end
+
+ command spell do
+ "#{spell_command} # one"
+ end
+
+ prompt 'fix english' do
+ append from command spell
+ 'Fix grammar'
+ end
+
+ file file_path do
+ agent 'count runs', 'fix english'
+ end
+ end
+
+ configure_from_scratch do
+ agent 'count runs' do
+ agent_command
+ end
+
+ command spell do
+ "#{spell_command} # two"
+ end
+
+ prompt 'fix english' do
+ append from command spell
+ 'Fix grammar'
+ end
+
+ file file_path do
+ agent 'count runs', 'fix english'
+ end
+ end
+
+ assert_equal 2, counter_value(counter_path)
+ end
+
+ def test_agent_processing_uses_separate_track_records_for_same_file_and_different_prompts
+ file_path = path('multiple-prompts.txt')
+ counter_path = path('multiple-prompts-counter.txt')
+ command = mock_agent_command(:count_pass_through, counter_path)
+ File.write(file_path, 'same content')
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt one do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'one'
+ end
+ end
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt two do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'two'
+ end
+ end
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt one do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'one'
+ end
+ end
+
+ assert_equal 2, counter_value(counter_path)
+ assert_equal 2, agent_cache_files.count
+ end
+
+ def test_failed_agent_run_does_not_refresh_track_record
+ file_path = path('failure-does-not-refresh.txt')
+ counter_path = path('failure-does-not-refresh-counter.txt')
+ marker_path = path('failure-marker.txt')
+ command = mock_agent_command(:count_or_fail, counter_path, marker_path, 'boom', '7')
+ File.write(file_path, 'same content')
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ File.write(file_path, 'changed content')
+ File.write(marker_path, 'boom')
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ File.delete(marker_path)
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ assert_equal 2, counter_value(counter_path)
+ end
+
+ def test_corrupt_agent_track_record_is_treated_as_stale
+ file_path = path('corrupt-record.txt')
+ counter_path = path('corrupt-record-counter.txt')
+ command = mock_agent_command(:count_pass_through, counter_path)
+ File.write(file_path, 'same content')
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ File.write(agent_cache_files.first, '{')
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ assert_equal 2, counter_value(counter_path)
+ end
+
def test_unknown_agent_raises
file_path = path('unknown-agent.txt')
File.write(file_path, 'hello')
@@ -268,6 +771,29 @@ class RCMAgentTest < Minitest::Test
end
end
+ def test_unknown_command_raises
+ file_path = path('unknown-command.txt')
+ agent_command = mock_agent_command(:pass_through)
+ File.write(file_path, 'hello')
+
+ assert_raises(RCM::DSL::NoSuchCommandDefinition) do
+ configure_from_scratch do
+ agent mock do
+ agent_command
+ end
+
+ prompt fix english do
+ append from command spell output
+ 'Fix grammar'
+ end
+
+ file file_path do
+ agent mock fix english
+ end
+ end
+ end
+ end
+
def test_missing_agent_input_raises
file_path = path('missing.txt')
command = mock_agent_command(:pass_through)
@@ -313,6 +839,60 @@ class RCMAgentTest < Minitest::Test
assert_equal 'keep me', File.read(file_path)
end
+ def test_dry_run_stale_agent_does_not_create_track_record
+ file_path = path('dry-run-no-cache.txt')
+ counter_path = path('dry-run-no-cache-counter.txt')
+ command = mock_agent_command(:count_pass_through, counter_path)
+ File.write(file_path, 'keep me')
+ ARGV.replace(['--dry'])
+
+ configure_from_scratch do
+ agent 'count runs' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'count runs', 'no op'
+ end
+ end
+
+ assert_equal 0, counter_value(counter_path)
+ assert_empty agent_cache_files
+ end
+
+ def test_dry_run_does_not_execute_prompt_command
+ file_path = path('dry-run-command.txt')
+ agent_command = mock_agent_command(:fail, 'agent boom', '7')
+ prompt_command = mock_agent_command(:fail, 'prompt boom', '8')
+ File.write(file_path, 'keep me')
+ ARGV.replace(['--dry'])
+
+ configure_from_scratch do
+ agent 'should not run' do
+ agent_command
+ end
+
+ command spell do
+ prompt_command
+ end
+
+ prompt fix english do
+ append from command spell
+ 'Fix grammar'
+ end
+
+ file file_path do
+ agent 'should not run', 'fix english'
+ end
+ end
+
+ assert_equal 'keep me', File.read(file_path)
+ end
+
def test_dry_run_unknown_agent_raises
file_path = path('dry-run-unknown-agent.txt')
File.write(file_path, 'keep me')
@@ -350,23 +930,160 @@ class RCMAgentTest < Minitest::Test
end
end
- def test_non_zero_exit_raises
+ def test_non_zero_exit_skips_file_and_continues_after_retries_are_exhausted
file_path = path('broken.txt')
- command = mock_agent_command(:fail, 'boom', '7')
+ next_file_path = path('still-runs.txt')
+ counter_path = path('broken-counter.txt')
+ command = mock_agent_command(:fail_then_pass, counter_path, '5', 'boom', '7')
+ File.write(file_path, 'hello')
+
+ configure_from_scratch do
+ agent 'broken agent' do
+ retry_delay 0
+ retry_backoff 1
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'broken agent', 'no op'
+ end
+
+ file next_file_path do
+ 'still runs'
+ end
+ end
+
+ assert_equal 'hello', File.read(file_path)
+ assert_equal 'still runs', File.read(next_file_path)
+ assert_equal 3, counter_value(counter_path)
+ assert_empty agent_cache_files
+ end
+
+ def test_agent_retries_failed_command_and_eventually_succeeds
+ file_path = path('retry-success.txt')
+ counter_path = path('retry-success-counter.txt')
+ command = mock_agent_command(:fail_then_pass, counter_path, '1', 'boom', '7')
+ File.write(file_path, 'hello')
+
+ configure_from_scratch do
+ agent 'flaky agent' do
+ retries 1
+ retry_delay 0
+ retry_backoff 1
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'flaky agent', 'no op'
+ end
+ end
+
+ assert_equal 'hello', File.read(file_path)
+ assert_equal 2, counter_value(counter_path)
+ end
+
+ def test_agent_skips_file_after_retries_are_exhausted
+ file_path = path('retry-failure.txt')
+ counter_path = path('retry-failure-counter.txt')
+ command = mock_agent_command(:fail_then_pass, counter_path, '5', 'boom', '7')
File.write(file_path, 'hello')
- error = assert_raises(RCM::File::AgentCommandFailed) do
+ configure_from_scratch do
+ agent 'flaky agent' do
+ retries 2
+ retry_delay 0
+ retry_backoff 1
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'flaky agent', 'no op'
+ end
+ end
+
+ assert_equal 'hello', File.read(file_path)
+ assert_equal 3, counter_value(counter_path)
+ assert_empty agent_cache_files
+ end
+
+ def test_invalid_agent_retry_settings_raise
+ assert_raises(RCM::AgentDefinition::InvalidRetrySetting) do
configure_from_scratch do
- agent 'broken agent' do
- command
+ agent 'broken retries' do
+ retries(-1)
+ 'ruby -e "print STDIN.read"'
end
+ end
+ end
- prompt 'no op' do
- ''
+ assert_raises(RCM::AgentDefinition::InvalidRetrySetting) do
+ configure_from_scratch do
+ agent 'broken backoff' do
+ retry_backoff 0.5
+ 'ruby -e "print STDIN.read"'
+ end
+ end
+ end
+ end
+
+ def test_agent_processing_writes_via_temporary_file
+ file_path = path('temporary-write.txt')
+ command = mock_agent_command(:upcase)
+ File.write(file_path, 'hello')
+
+ configure_from_scratch do
+ agent 'make loud' do
+ command
+ end
+
+ prompt 'no op' do
+ ''
+ end
+
+ file file_path do
+ agent 'make loud', 'no op'
+ end
+ end
+
+ assert_equal 'HELLO', File.read(file_path)
+ refute File.exist?("#{file_path}.rcmtmp")
+ end
+
+ def test_non_zero_prompt_command_exit_raises
+ file_path = path('broken-prompt-command.txt')
+ agent_command = mock_agent_command(:pass_through)
+ prompt_command = mock_agent_command(:fail, 'boom', '7')
+ File.write(file_path, 'hello')
+
+ error = assert_raises(RCM::PromptDefinition::CommandFailed) do
+ configure_from_scratch do
+ agent mock do
+ agent_command
+ end
+
+ command spell do
+ prompt_command
+ end
+
+ prompt fix english do
+ append from command spell
+ 'Fix grammar'
end
file file_path do
- agent 'broken agent', 'no op'
+ agent mock fix english
end
end
end
diff --git a/test/lib/dslkeywords/file_test.rb b/test/lib/dslkeywords/file_test.rb
index a290627..124a612 100644
--- a/test/lib/dslkeywords/file_test.rb
+++ b/test/lib/dslkeywords/file_test.rb
@@ -1,11 +1,17 @@
+# frozen_string_literal: true
+
+# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
require 'minitest/autorun'
require 'fileutils'
+require 'rbconfig'
+require 'shellwords'
require_relative '../../../lib/dsl'
class RCMFileTest < Minitest::Test
- FILE_PATH = './.file_test.rcmtmp'.freeze
- DIR_PATH = './.dir_test.rcmtmp'.freeze
+ FILE_PATH = './.file_test.rcmtmp'
+ DIR_PATH = './.dir_test.rcmtmp'
+ MOCK_COMMAND = File.expand_path('../../support/mock_agent.rb', __dir__).freeze
Minitest.after_run do
File.unlink(FILE_PATH) if File.file?(FILE_PATH)
@@ -14,10 +20,28 @@ class RCMFileTest < Minitest::Test
# Clean up shared temp file between tests to prevent order-dependent failures
def setup
+ @original_argv = ARGV.dup
File.unlink(FILE_PATH) if File.file?(FILE_PATH)
FileUtils.rm_r(DIR_PATH) if File.directory?(DIR_PATH)
end
+ def teardown
+ ARGV.replace(@original_argv) if @original_argv
+ end
+
+ def mock_command(mode, *args)
+ parts = [RbConfig.ruby, MOCK_COMMAND, mode.to_s]
+ args.each do |arg|
+ parts << if %w[INPUT PROMPT FILE_PATH].include?(arg)
+ arg
+ else
+ Shellwords.escape(arg.to_s)
+ end
+ end
+
+ [Shellwords.escape(parts.shift), Shellwords.escape(parts.shift), Shellwords.escape(parts.shift), *parts].join(' ')
+ end
+
def test_create_file_from_string
text = 'Hello World!'
configure_from_scratch do
@@ -104,6 +128,58 @@ class RCMFileTest < Minitest::Test
assert_equal 'One plus two is 3!', File.read(FILE_PATH)
end
+ def test_file_appends_command_output
+ command_template = mock_command(:join_args, 'suffix', 'FILE_PATH')
+
+ configure_from_scratch do
+ command suffix do
+ command_template
+ end
+
+ file FILE_PATH do
+ append from command suffix
+ 'Hello World!'
+ end
+ end
+
+ assert_equal "Hello World!\nsuffix|#{FILE_PATH}", File.read(FILE_PATH)
+ end
+
+ def test_file_prepends_command_output
+ command_template = mock_command(:join_args, 'prefix', 'FILE_PATH')
+
+ configure_from_scratch do
+ command prefix do
+ command_template
+ end
+
+ file FILE_PATH do
+ prepend from command prefix
+ 'Hello World!'
+ end
+ end
+
+ assert_equal "prefix|#{FILE_PATH}\nHello World!", File.read(FILE_PATH)
+ end
+
+ def test_file_appends_command_output_to_template_content
+ command_template = mock_command(:join_args, 'suffix', 'FILE_PATH')
+
+ configure_from_scratch do
+ command suffix do
+ command_template
+ end
+
+ file FILE_PATH do
+ append from command suffix
+ from template
+ 'One plus two is <%= 1 + 2 %>!'
+ end
+ end
+
+ assert_equal "One plus two is 3!\nsuffix|#{FILE_PATH}", File.read(FILE_PATH)
+ end
+
def test_line
File.write(FILE_PATH, "Hey there\n")
configure_from_scratch { file(FILE_PATH) { line 'Whats up?' } }
@@ -146,7 +222,8 @@ class RCMFileTest < Minitest::Test
def test_backup
file_path = "#{DIR_PATH}/foo/backup-me.txt"
original_content = 'original_content'
- backup_path = "#{DIR_PATH}/foo/.rcmbackup/backup-me.txt.d4c3af73588ce06c32ed04d1b79801286109ea265712a2bd3fdc3ed01c82bb86"
+ backup_path =
+ "#{DIR_PATH}/foo/.rcmbackup/backup-me.txt.d4c3af73588ce06c32ed04d1b79801286109ea265712a2bd3fdc3ed01c82bb86"
configure_from_scratch do
file original do
@@ -168,4 +245,57 @@ class RCMFileTest < Minitest::Test
assert File.file?(file_path)
assert_equal :new_content.to_s, File.read(file_path)
end
+
+ def test_unknown_command_raises_for_file_content
+ error = assert_raises(RCM::DSL::NoSuchCommandDefinition) do
+ configure_from_scratch do
+ file FILE_PATH do
+ append from command suffix
+ 'Hello World!'
+ end
+ end
+ end
+
+ assert_match("No such command 'suffix'", error.message)
+ end
+
+ def test_non_zero_command_exit_raises_for_file_content
+ command_template = mock_command(:fail, 'boom', '7')
+
+ error = assert_raises(RCM::File::CommandFailed) do
+ configure_from_scratch do
+ command explode do
+ command_template
+ end
+
+ file FILE_PATH do
+ append from command explode
+ 'Hello World!'
+ end
+ end
+ end
+
+ assert_match('exit 7', error.message)
+ assert_match('boom', error.message)
+ end
+
+ def test_dry_run_does_not_execute_file_command
+ command_template = mock_command(:fail, 'boom', '7')
+ ARGV.replace(['--dry'])
+
+ configure_from_scratch do
+ command explode do
+ command_template
+ end
+
+ file FILE_PATH do
+ append from command explode
+ 'Hello World!'
+ end
+ end
+
+ refute File.exist?(FILE_PATH)
+ end
end
+
+# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
diff --git a/test/support/mock_agent.rb b/test/support/mock_agent.rb
index b86de4f..9c21345 100644
--- a/test/support/mock_agent.rb
+++ b/test/support/mock_agent.rb
@@ -13,6 +13,52 @@ when 'reverse_input'
when 'basename'
file_path = ARGV.fetch(0)
print File.basename(file_path)
+when 'join_args'
+ print ARGV.join('|')
+when 'count_pass_through'
+ counter_path = ARGV.fetch(0)
+ count = File.file?(counter_path) ? File.read(counter_path).to_i : 0
+ File.write(counter_path, count + 1)
+ print stdin
+when 'count_upcase'
+ counter_path = ARGV.fetch(0)
+ count = File.file?(counter_path) ? File.read(counter_path).to_i : 0
+ File.write(counter_path, count + 1)
+ print stdin.upcase
+when 'fail_then_pass'
+ counter_path = ARGV.fetch(0)
+ failures_before_success = Integer(ARGV.fetch(1, '1'))
+ message = ARGV.fetch(2, 'boom')
+ exit_code = Integer(ARGV.fetch(3, '7'))
+ count = File.file?(counter_path) ? File.read(counter_path).to_i : 0
+ count += 1
+ File.write(counter_path, count)
+ if count <= failures_before_success
+ warn message
+ exit exit_code
+ end
+ print stdin
+when 'stream_chunks'
+ stderr_text = ARGV.pop.sub('--stderr=', '') if ARGV.last&.start_with?('--stderr=')
+ ARGV.each do |chunk|
+ $stdout.write(chunk)
+ $stdout.flush
+ sleep 0.01
+ end
+ if stderr_text
+ $stderr.write(stderr_text)
+ $stderr.flush
+ end
+when 'count_or_fail'
+ counter_path = ARGV.fetch(0)
+ marker_path = ARGV.fetch(1)
+ if File.exist?(marker_path)
+ warn ARGV.fetch(2, 'boom')
+ exit Integer(ARGV.fetch(3, '7'))
+ end
+ count = File.file?(counter_path) ? File.read(counter_path).to_i : 0
+ File.write(counter_path, count + 1)
+ print stdin
when 'pass_through'
print stdin
when 'upcase'