rubyn-code 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Autonomous
|
|
5
|
+
# Polls for new work when an agent is idle. Checks the mailbox
|
|
6
|
+
# (messages always take priority) and the task board on a regular
|
|
7
|
+
# interval. Blocks the calling thread until work is found, the
|
|
8
|
+
# idle timeout expires, or the poller is interrupted.
|
|
9
|
+
class IdlePoller
|
|
10
|
+
# @param mailbox [#pending_for] message mailbox
|
|
11
|
+
# @param task_manager [#db] task persistence layer
|
|
12
|
+
# @param agent_name [String] the polling agent's identifier
|
|
13
|
+
# @param poll_interval [Numeric] seconds between polls (default 5)
|
|
14
|
+
# @param idle_timeout [Numeric] max seconds to wait before shutdown (default 60)
|
|
15
|
+
def initialize(mailbox:, task_manager:, agent_name:, poll_interval: 5, idle_timeout: 60)
|
|
16
|
+
@mailbox = mailbox
|
|
17
|
+
@task_manager = task_manager
|
|
18
|
+
@agent_name = agent_name
|
|
19
|
+
@poll_interval = poll_interval
|
|
20
|
+
@idle_timeout = idle_timeout
|
|
21
|
+
@interrupted = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Blocks the caller, polling for new work at the configured interval.
|
|
25
|
+
#
|
|
26
|
+
# @return [:resume, :shutdown, :interrupted]
|
|
27
|
+
# - :resume - found work (message or task)
|
|
28
|
+
# - :shutdown - idle timeout elapsed with no work
|
|
29
|
+
# - :interrupted - #interrupt! was called externally
|
|
30
|
+
def poll!
|
|
31
|
+
deadline = monotonic_now + @idle_timeout
|
|
32
|
+
|
|
33
|
+
loop do
|
|
34
|
+
return :interrupted if @interrupted
|
|
35
|
+
return :shutdown if monotonic_now >= deadline
|
|
36
|
+
|
|
37
|
+
# Messages always take priority over tasks.
|
|
38
|
+
if has_pending_messages?
|
|
39
|
+
return :resume
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if has_claimable_task?
|
|
43
|
+
return :resume
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
remaining = deadline - monotonic_now
|
|
47
|
+
return :shutdown if remaining <= 0
|
|
48
|
+
|
|
49
|
+
sleep [remaining, @poll_interval].min
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Signals the poller to stop at the next iteration.
|
|
54
|
+
#
|
|
55
|
+
# @return [void]
|
|
56
|
+
def interrupt!
|
|
57
|
+
@interrupted = true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Re-injects the agent's identity message when the conversation
|
|
61
|
+
# context has been compressed (i.e. the messages array is very short).
|
|
62
|
+
# This ensures the agent still knows who it is after compaction.
|
|
63
|
+
#
|
|
64
|
+
# @param messages [Array<Hash>] the current conversation messages
|
|
65
|
+
# @param identity [String] the identity/system prompt to re-inject
|
|
66
|
+
# @param threshold [Integer] message count below which re-injection triggers (default 3)
|
|
67
|
+
# @return [void]
|
|
68
|
+
def self.reinject_identity(messages, identity:, threshold: 3)
|
|
69
|
+
return if messages.length >= threshold
|
|
70
|
+
return if identity.nil? || identity.empty?
|
|
71
|
+
|
|
72
|
+
# Only re-inject if the identity is not already present as the
|
|
73
|
+
# first user message.
|
|
74
|
+
first_user = messages.find { |m| m[:role] == "user" }
|
|
75
|
+
return if first_user && first_user[:content].to_s.include?(identity[0, 100])
|
|
76
|
+
|
|
77
|
+
messages.unshift({ role: "user", content: identity })
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# @return [Boolean]
|
|
83
|
+
def has_pending_messages?
|
|
84
|
+
messages = @mailbox.pending_for(@agent_name)
|
|
85
|
+
messages.is_a?(Array) ? !messages.empty? : false
|
|
86
|
+
rescue StandardError
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Boolean]
|
|
91
|
+
def has_claimable_task?
|
|
92
|
+
rows = @task_manager.db.query(<<~SQL).to_a
|
|
93
|
+
SELECT 1 FROM tasks
|
|
94
|
+
WHERE status = 'pending'
|
|
95
|
+
AND (owner IS NULL OR owner = '')
|
|
96
|
+
LIMIT 1
|
|
97
|
+
SQL
|
|
98
|
+
!rows.empty?
|
|
99
|
+
rescue StandardError
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Monotonic clock to avoid issues with wall-clock adjustments.
|
|
104
|
+
#
|
|
105
|
+
# @return [Float] seconds
|
|
106
|
+
def monotonic_now
|
|
107
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Autonomous
|
|
5
|
+
# Claims and prepares unclaimed tasks for agent execution.
|
|
6
|
+
# Uses optimistic locking to handle race conditions when multiple
|
|
7
|
+
# agents attempt to claim the same task concurrently.
|
|
8
|
+
module TaskClaimer
|
|
9
|
+
# Finds the first ready (pending, unowned) task, claims it for the
|
|
10
|
+
# given agent, and returns the updated Task. Returns nil if no work
|
|
11
|
+
# is available.
|
|
12
|
+
#
|
|
13
|
+
# @param task_manager [#db, #update_task, #list_tasks] task persistence layer
|
|
14
|
+
# @param agent_name [String] unique identifier of the claiming agent
|
|
15
|
+
# @return [Tasks::Task, nil] the claimed task, or nil if none available
|
|
16
|
+
def self.call(task_manager:, agent_name:)
|
|
17
|
+
db = task_manager.db
|
|
18
|
+
|
|
19
|
+
# Atomically claim the first eligible task. The WHERE conditions
|
|
20
|
+
# ensure that only pending tasks with no current owner are touched,
|
|
21
|
+
# avoiding race conditions with other agents.
|
|
22
|
+
db.execute(<<~SQL, [agent_name])
|
|
23
|
+
UPDATE tasks
|
|
24
|
+
SET owner = ?,
|
|
25
|
+
status = 'in_progress',
|
|
26
|
+
updated_at = datetime('now')
|
|
27
|
+
WHERE id = (
|
|
28
|
+
SELECT id FROM tasks
|
|
29
|
+
WHERE status = 'pending'
|
|
30
|
+
AND (owner IS NULL OR owner = '')
|
|
31
|
+
ORDER BY priority DESC, created_at ASC
|
|
32
|
+
LIMIT 1
|
|
33
|
+
)
|
|
34
|
+
AND status = 'pending'
|
|
35
|
+
AND (owner IS NULL OR owner = '')
|
|
36
|
+
SQL
|
|
37
|
+
|
|
38
|
+
# Fetch the task we just claimed. Using owner + status filters
|
|
39
|
+
# ensures we only retrieve a task that *this* agent successfully
|
|
40
|
+
# claimed (another agent cannot have flipped it in between).
|
|
41
|
+
rows = db.query(<<~SQL, [agent_name]).to_a
|
|
42
|
+
SELECT id, session_id, title, description, status,
|
|
43
|
+
priority, owner, result, metadata, created_at, updated_at
|
|
44
|
+
FROM tasks
|
|
45
|
+
WHERE owner = ?
|
|
46
|
+
AND status = 'in_progress'
|
|
47
|
+
ORDER BY updated_at DESC
|
|
48
|
+
LIMIT 1
|
|
49
|
+
SQL
|
|
50
|
+
|
|
51
|
+
return nil if rows.empty?
|
|
52
|
+
|
|
53
|
+
row = rows.first
|
|
54
|
+
build_task(row)
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
# If anything goes wrong (e.g. task was already claimed between
|
|
57
|
+
# our SELECT and UPDATE, or a constraint violation) we treat it
|
|
58
|
+
# as "no work available" rather than crashing the daemon.
|
|
59
|
+
RubynCode.logger.warn("TaskClaimer: failed to claim task: #{e.message}") if RubynCode.respond_to?(:logger)
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# @param row [Hash] a database row hash
|
|
67
|
+
# @return [Tasks::Task]
|
|
68
|
+
def build_task(row)
|
|
69
|
+
metadata = parse_json(row["metadata"])
|
|
70
|
+
|
|
71
|
+
Tasks::Task.new(
|
|
72
|
+
id: row["id"],
|
|
73
|
+
session_id: row["session_id"],
|
|
74
|
+
title: row["title"],
|
|
75
|
+
description: row["description"],
|
|
76
|
+
status: row["status"],
|
|
77
|
+
priority: row["priority"].to_i,
|
|
78
|
+
owner: row["owner"],
|
|
79
|
+
result: row["result"],
|
|
80
|
+
metadata: metadata,
|
|
81
|
+
created_at: row["created_at"],
|
|
82
|
+
updated_at: row["updated_at"]
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param raw [String, Hash, nil]
|
|
87
|
+
# @return [Hash]
|
|
88
|
+
def parse_json(raw)
|
|
89
|
+
case raw
|
|
90
|
+
when Hash then raw
|
|
91
|
+
when String then JSON.parse(raw, symbolize_names: true)
|
|
92
|
+
else {}
|
|
93
|
+
end
|
|
94
|
+
rescue JSON::ParserError
|
|
95
|
+
{}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Background
|
|
5
|
+
Job = Data.define(:id, :command, :status, :result, :started_at, :completed_at) do
|
|
6
|
+
def running? = status == :running
|
|
7
|
+
def completed? = status == :completed
|
|
8
|
+
def error? = status == :error
|
|
9
|
+
def timeout? = status == :timeout
|
|
10
|
+
|
|
11
|
+
def duration
|
|
12
|
+
return nil unless started_at
|
|
13
|
+
return nil if running?
|
|
14
|
+
|
|
15
|
+
(completed_at || Time.now) - started_at
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module Background
|
|
7
|
+
# Thread-safe notification queue for background job completions.
|
|
8
|
+
# Uses Ruby's stdlib Queue which is already thread-safe for push/pop,
|
|
9
|
+
# but we guard drain with a mutex to prevent interleaved partial drains.
|
|
10
|
+
class Notifier
|
|
11
|
+
def initialize
|
|
12
|
+
@queue = Queue.new
|
|
13
|
+
@drain_mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Enqueues a notification.
|
|
17
|
+
#
|
|
18
|
+
# @param notification [Hash, String, Object] arbitrary notification payload
|
|
19
|
+
# @return [void]
|
|
20
|
+
def push(notification)
|
|
21
|
+
@queue.push(notification)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Drains all pending notifications in a single atomic operation.
|
|
25
|
+
# Returns an empty array if nothing is pending.
|
|
26
|
+
#
|
|
27
|
+
# @return [Array] all pending notifications
|
|
28
|
+
def drain
|
|
29
|
+
@drain_mutex.synchronize do
|
|
30
|
+
notifications = []
|
|
31
|
+
notifications << @queue.pop until @queue.empty?
|
|
32
|
+
notifications
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns true if there are notifications waiting.
|
|
37
|
+
#
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
def pending?
|
|
40
|
+
!@queue.empty?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require_relative "job"
|
|
7
|
+
require_relative "notifier"
|
|
8
|
+
|
|
9
|
+
module RubynCode
|
|
10
|
+
module Background
|
|
11
|
+
# Runs shell commands in background threads with configurable timeouts.
|
|
12
|
+
# Thread-safe job tracking with a hard cap on concurrency.
|
|
13
|
+
class Worker
|
|
14
|
+
MAX_CONCURRENT = 5
|
|
15
|
+
|
|
16
|
+
# @param project_root [String] working directory for spawned commands
|
|
17
|
+
# @param notifier [Notifier] notification queue for completed jobs
|
|
18
|
+
def initialize(project_root:, notifier: Notifier.new)
|
|
19
|
+
@project_root = File.expand_path(project_root)
|
|
20
|
+
@notifier = notifier
|
|
21
|
+
@jobs = {}
|
|
22
|
+
@threads = {}
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Spawns a background thread to run the given command.
|
|
27
|
+
#
|
|
28
|
+
# @param command [String] the shell command to execute
|
|
29
|
+
# @param timeout [Integer] timeout in seconds (default 120)
|
|
30
|
+
# @return [String] the job ID
|
|
31
|
+
# @raise [RuntimeError] if the concurrency cap is reached
|
|
32
|
+
def run(command, timeout: 120)
|
|
33
|
+
job_id = SecureRandom.uuid
|
|
34
|
+
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
running = @jobs.count { |_, j| j.running? }
|
|
37
|
+
if running >= MAX_CONCURRENT
|
|
38
|
+
raise "Concurrency limit reached (#{MAX_CONCURRENT} jobs running). Wait for a job to finish."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
job = Job.new(
|
|
42
|
+
id: job_id,
|
|
43
|
+
command: command,
|
|
44
|
+
status: :running,
|
|
45
|
+
result: nil,
|
|
46
|
+
started_at: Time.now,
|
|
47
|
+
completed_at: nil
|
|
48
|
+
)
|
|
49
|
+
@jobs[job_id] = job
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
thread = Thread.new { execute_job(job_id, command, timeout) }
|
|
53
|
+
thread.abort_on_exception = false
|
|
54
|
+
|
|
55
|
+
@mutex.synchronize { @threads[job_id] = thread }
|
|
56
|
+
|
|
57
|
+
job_id
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns the current state of a job.
|
|
61
|
+
#
|
|
62
|
+
# @param job_id [String]
|
|
63
|
+
# @return [Job, nil]
|
|
64
|
+
def status(job_id)
|
|
65
|
+
@mutex.synchronize { @jobs[job_id] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Delegates to the notifier to drain all pending notifications.
|
|
69
|
+
#
|
|
70
|
+
# @return [Array]
|
|
71
|
+
def drain_notifications
|
|
72
|
+
@notifier.drain
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Returns the number of currently running jobs.
|
|
76
|
+
#
|
|
77
|
+
# @return [Integer]
|
|
78
|
+
def active_count
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
@jobs.count { |_, j| j.running? }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Waits for all running threads to finish. Intended for graceful shutdown.
|
|
85
|
+
#
|
|
86
|
+
# @param timeout [Integer] maximum seconds to wait per thread (default 30)
|
|
87
|
+
# @return [void]
|
|
88
|
+
def shutdown!(timeout: 30)
|
|
89
|
+
threads = @mutex.synchronize { @threads.values.dup }
|
|
90
|
+
threads.each { |t| t.join(timeout) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def execute_job(job_id, command, timeout_seconds)
|
|
96
|
+
stdout, stderr, process_status = nil
|
|
97
|
+
final_status = :completed
|
|
98
|
+
|
|
99
|
+
begin
|
|
100
|
+
Timeout.timeout(timeout_seconds) do
|
|
101
|
+
stdout, stderr, process_status = Open3.capture3(command, chdir: @project_root)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
final_status = process_status.success? ? :completed : :error
|
|
105
|
+
rescue Timeout::Error
|
|
106
|
+
final_status = :timeout
|
|
107
|
+
stdout = nil
|
|
108
|
+
stderr = "Command timed out after #{timeout_seconds} seconds"
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
final_status = :error
|
|
111
|
+
stdout = nil
|
|
112
|
+
stderr = e.message
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
result = build_result(stdout, stderr)
|
|
116
|
+
completed_at = Time.now
|
|
117
|
+
|
|
118
|
+
completed_job = @mutex.synchronize do
|
|
119
|
+
@jobs[job_id] = Job.new(
|
|
120
|
+
id: job_id,
|
|
121
|
+
command: command,
|
|
122
|
+
status: final_status,
|
|
123
|
+
result: result,
|
|
124
|
+
started_at: @jobs[job_id].started_at,
|
|
125
|
+
completed_at: completed_at
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
@notifier.push({
|
|
130
|
+
type: :job_completed,
|
|
131
|
+
job_id: job_id,
|
|
132
|
+
status: final_status,
|
|
133
|
+
result: result,
|
|
134
|
+
duration: completed_job.duration
|
|
135
|
+
})
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def build_result(stdout, stderr)
|
|
139
|
+
parts = []
|
|
140
|
+
parts << stdout if stdout && !stdout.empty?
|
|
141
|
+
parts << "STDERR: #{stderr}" if stderr && !stderr.empty?
|
|
142
|
+
parts.empty? ? "(no output)" : parts.join("\n")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
class App
|
|
6
|
+
def self.start(argv)
|
|
7
|
+
new(argv).run
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(argv)
|
|
11
|
+
@argv = argv
|
|
12
|
+
@options = parse_options(argv)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
case @options[:command]
|
|
17
|
+
when :version
|
|
18
|
+
puts "rubyn-code #{RubynCode::VERSION}"
|
|
19
|
+
when :auth
|
|
20
|
+
run_auth
|
|
21
|
+
when :help
|
|
22
|
+
display_help
|
|
23
|
+
when :run
|
|
24
|
+
run_single_prompt(@options[:prompt])
|
|
25
|
+
when :repl
|
|
26
|
+
run_repl
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def parse_options(argv)
|
|
33
|
+
options = { command: :repl }
|
|
34
|
+
|
|
35
|
+
i = 0
|
|
36
|
+
while i < argv.length
|
|
37
|
+
case argv[i]
|
|
38
|
+
when "--version", "-v"
|
|
39
|
+
options[:command] = :version
|
|
40
|
+
when "--help", "-h"
|
|
41
|
+
options[:command] = :help
|
|
42
|
+
when "--auth"
|
|
43
|
+
options[:command] = :auth
|
|
44
|
+
when "--resume", "-r"
|
|
45
|
+
options[:session_id] = argv[i + 1]
|
|
46
|
+
i += 1
|
|
47
|
+
when "-p", "--prompt"
|
|
48
|
+
options[:command] = :run
|
|
49
|
+
options[:prompt] = argv[i + 1]
|
|
50
|
+
i += 1
|
|
51
|
+
when "--yolo"
|
|
52
|
+
options[:yolo] = true
|
|
53
|
+
end
|
|
54
|
+
i += 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
options
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def run_auth
|
|
61
|
+
renderer = Renderer.new
|
|
62
|
+
renderer.info("Starting Claude OAuth authentication...")
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
Auth::OAuth.new.authenticate!
|
|
66
|
+
renderer.success("Authentication successful! Token stored.")
|
|
67
|
+
rescue AuthenticationError => e
|
|
68
|
+
renderer.error("Authentication failed: #{e.message}")
|
|
69
|
+
exit(1)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def run_single_prompt(prompt)
|
|
74
|
+
return display_help unless prompt
|
|
75
|
+
|
|
76
|
+
repl = REPL.new(project_root: Dir.pwd)
|
|
77
|
+
# Non-interactive: send one message and exit
|
|
78
|
+
response = repl.instance_variable_get(:@agent_loop).send_message(prompt)
|
|
79
|
+
puts response
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def run_repl
|
|
83
|
+
REPL.new(
|
|
84
|
+
session_id: @options[:session_id],
|
|
85
|
+
project_root: Dir.pwd,
|
|
86
|
+
yolo: @options[:yolo]
|
|
87
|
+
).run
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def display_help
|
|
91
|
+
puts <<~HELP
|
|
92
|
+
rubyn-code - Ruby & Rails Agentic Coding Assistant
|
|
93
|
+
|
|
94
|
+
Usage:
|
|
95
|
+
rubyn-code Start interactive REPL
|
|
96
|
+
rubyn-code -p "prompt" Run a single prompt and exit
|
|
97
|
+
rubyn-code --resume [ID] Resume a previous session
|
|
98
|
+
rubyn-code --auth Authenticate with Claude
|
|
99
|
+
rubyn-code --version Show version
|
|
100
|
+
rubyn-code --help Show this help
|
|
101
|
+
|
|
102
|
+
Interactive Commands:
|
|
103
|
+
/help Show available commands
|
|
104
|
+
/quit Exit
|
|
105
|
+
/compact Compress context
|
|
106
|
+
/cost Show usage costs
|
|
107
|
+
/tasks List tasks
|
|
108
|
+
/skill [name] Load or list skills
|
|
109
|
+
|
|
110
|
+
Environment:
|
|
111
|
+
Config: ~/.rubyn-code/config.yml
|
|
112
|
+
Data: ~/.rubyn-code/rubyn_code.db
|
|
113
|
+
Tokens: ~/.rubyn-code/tokens.yml
|
|
114
|
+
HELP
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
class InputHandler
|
|
6
|
+
SLASH_COMMANDS = {
|
|
7
|
+
"/quit" => :quit,
|
|
8
|
+
"/exit" => :quit,
|
|
9
|
+
"/q" => :quit,
|
|
10
|
+
"/compact" => :compact,
|
|
11
|
+
"/cost" => :cost,
|
|
12
|
+
"/clear" => :clear,
|
|
13
|
+
"/undo" => :undo,
|
|
14
|
+
"/help" => :help,
|
|
15
|
+
"/tasks" => :tasks,
|
|
16
|
+
"/budget" => :budget,
|
|
17
|
+
"/resume" => :resume,
|
|
18
|
+
"/skill" => :skill,
|
|
19
|
+
"/version" => :version,
|
|
20
|
+
"/review" => :review,
|
|
21
|
+
"/spawn" => :spawn_teammate
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
Command = Data.define(:action, :args)
|
|
25
|
+
|
|
26
|
+
def parse(input)
|
|
27
|
+
return Command.new(action: :quit, args: []) if input.nil?
|
|
28
|
+
|
|
29
|
+
stripped = input.strip
|
|
30
|
+
return Command.new(action: :empty, args: []) if stripped.empty?
|
|
31
|
+
|
|
32
|
+
if stripped.start_with?("/")
|
|
33
|
+
parse_slash_command(stripped)
|
|
34
|
+
else
|
|
35
|
+
Command.new(action: :message, args: [process_file_references(stripped)])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def multiline?(line)
|
|
40
|
+
line&.end_with?("\\")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def strip_continuation(line)
|
|
44
|
+
line.chomp("\\")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def parse_slash_command(input)
|
|
50
|
+
return Command.new(action: :list_commands, args: []) if input.strip == "/"
|
|
51
|
+
|
|
52
|
+
parts = input.split(/\s+/, 2)
|
|
53
|
+
cmd = parts[0].downcase
|
|
54
|
+
args = parts[1]&.split(/\s+/) || []
|
|
55
|
+
|
|
56
|
+
action = SLASH_COMMANDS[cmd]
|
|
57
|
+
|
|
58
|
+
if action
|
|
59
|
+
Command.new(action: action, args: args)
|
|
60
|
+
else
|
|
61
|
+
Command.new(action: :unknown_command, args: [cmd])
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def process_file_references(input)
|
|
66
|
+
input.gsub(/@(\S+)/) do |match|
|
|
67
|
+
path = Regexp.last_match(1)
|
|
68
|
+
if File.exist?(path)
|
|
69
|
+
content = File.read(path, encoding: "utf-8")
|
|
70
|
+
truncated = content.length > 50_000 ? "#{content[0...50_000]}\n[truncated]" : content
|
|
71
|
+
"\n<file path=\"#{path}\">\n#{truncated}\n</file>\n"
|
|
72
|
+
else
|
|
73
|
+
match
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|