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,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module Tasks
|
|
7
|
+
# Directed acyclic graph tracking task dependencies.
|
|
8
|
+
# Backed by a SQLite table for persistence; keeps an in-memory
|
|
9
|
+
# adjacency list for fast traversal.
|
|
10
|
+
class DAG
|
|
11
|
+
# @param db [DB::Connection]
|
|
12
|
+
def initialize(db)
|
|
13
|
+
@db = db
|
|
14
|
+
@forward = Hash.new { |h, k| h[k] = Set.new } # task_id -> depends_on ids
|
|
15
|
+
@reverse = Hash.new { |h, k| h[k] = Set.new } # task_id -> dependent ids
|
|
16
|
+
ensure_table
|
|
17
|
+
load_from_db
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Declares that +task_id+ depends on +depends_on_id+.
|
|
21
|
+
#
|
|
22
|
+
# @param task_id [String]
|
|
23
|
+
# @param depends_on_id [String]
|
|
24
|
+
# @raise [ArgumentError] if this would create a cycle
|
|
25
|
+
# @return [void]
|
|
26
|
+
def add_dependency(task_id, depends_on_id)
|
|
27
|
+
raise ArgumentError, "A task cannot depend on itself" if task_id == depends_on_id
|
|
28
|
+
raise ArgumentError, "Cycle detected" if reachable?(depends_on_id, task_id)
|
|
29
|
+
|
|
30
|
+
return if @forward[task_id].include?(depends_on_id)
|
|
31
|
+
|
|
32
|
+
@db.execute(
|
|
33
|
+
"INSERT OR IGNORE INTO task_dependencies (task_id, depends_on_id) VALUES (?, ?)",
|
|
34
|
+
[task_id, depends_on_id]
|
|
35
|
+
)
|
|
36
|
+
@forward[task_id].add(depends_on_id)
|
|
37
|
+
@reverse[depends_on_id].add(task_id)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Removes a dependency edge.
|
|
41
|
+
#
|
|
42
|
+
# @param task_id [String]
|
|
43
|
+
# @param depends_on_id [String]
|
|
44
|
+
# @return [void]
|
|
45
|
+
def remove_dependency(task_id, depends_on_id)
|
|
46
|
+
@db.execute(
|
|
47
|
+
"DELETE FROM task_dependencies WHERE task_id = ? AND depends_on_id = ?",
|
|
48
|
+
[task_id, depends_on_id]
|
|
49
|
+
)
|
|
50
|
+
@forward[task_id].delete(depends_on_id)
|
|
51
|
+
@reverse[depends_on_id].delete(task_id)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns the IDs of tasks that +task_id+ directly depends on.
|
|
55
|
+
#
|
|
56
|
+
# @param task_id [String]
|
|
57
|
+
# @return [Array<String>]
|
|
58
|
+
def dependencies_for(task_id)
|
|
59
|
+
@forward[task_id].to_a
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns the IDs of tasks that directly depend on +task_id+.
|
|
63
|
+
#
|
|
64
|
+
# @param task_id [String]
|
|
65
|
+
# @return [Array<String>]
|
|
66
|
+
def dependents_of(task_id)
|
|
67
|
+
@reverse[task_id].to_a
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns true if +task_id+ has any incomplete dependency.
|
|
71
|
+
#
|
|
72
|
+
# @param task_id [String]
|
|
73
|
+
# @return [Boolean]
|
|
74
|
+
def blocked?(task_id)
|
|
75
|
+
deps = @forward[task_id]
|
|
76
|
+
return false if deps.empty?
|
|
77
|
+
|
|
78
|
+
rows = @db.query(
|
|
79
|
+
"SELECT id FROM tasks WHERE id IN (#{placeholders(deps.size)}) AND status != 'completed'",
|
|
80
|
+
deps.to_a
|
|
81
|
+
).to_a
|
|
82
|
+
!rows.empty?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Called when a task is completed. Removes it as a blocker from
|
|
86
|
+
# every dependent, flipping dependents from 'blocked' to 'pending'
|
|
87
|
+
# when all their deps are met.
|
|
88
|
+
#
|
|
89
|
+
# @param completed_task_id [String]
|
|
90
|
+
# @return [Array<String>] IDs of tasks that were unblocked
|
|
91
|
+
def unblock_cascade(completed_task_id)
|
|
92
|
+
unblocked = []
|
|
93
|
+
|
|
94
|
+
dependents_of(completed_task_id).each do |dep_id|
|
|
95
|
+
next if blocked?(dep_id)
|
|
96
|
+
|
|
97
|
+
rows = @db.query("SELECT status FROM tasks WHERE id = ?", [dep_id]).to_a
|
|
98
|
+
next if rows.empty?
|
|
99
|
+
|
|
100
|
+
current_status = rows.first["status"]
|
|
101
|
+
next unless current_status == "blocked"
|
|
102
|
+
|
|
103
|
+
@db.execute(
|
|
104
|
+
"UPDATE tasks SET status = 'pending', updated_at = datetime('now') WHERE id = ?",
|
|
105
|
+
[dep_id]
|
|
106
|
+
)
|
|
107
|
+
unblocked << dep_id
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
unblocked
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Returns all known task IDs in a valid execution order (dependencies first).
|
|
114
|
+
#
|
|
115
|
+
# @return [Array<String>]
|
|
116
|
+
# @raise [RuntimeError] if the graph contains a cycle
|
|
117
|
+
def topological_sort
|
|
118
|
+
in_degree = Hash.new(0)
|
|
119
|
+
all_nodes = Set.new
|
|
120
|
+
|
|
121
|
+
@forward.each do |task_id, deps|
|
|
122
|
+
all_nodes.add(task_id)
|
|
123
|
+
deps.each do |dep_id|
|
|
124
|
+
all_nodes.add(dep_id)
|
|
125
|
+
in_degree[dep_id] # touch to initialize
|
|
126
|
+
in_degree[task_id] += 1 # task_id depends on dep_id, so task_id has higher in-degree
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Nodes with no dependencies come first
|
|
131
|
+
# Note: in our graph, forward[task_id] = set of tasks task_id depends ON,
|
|
132
|
+
# so the "edges" for topological sort point from dep -> task_id.
|
|
133
|
+
in_degree_corrected = Hash.new(0)
|
|
134
|
+
all_nodes.each { |n| in_degree_corrected[n] = 0 }
|
|
135
|
+
|
|
136
|
+
@forward.each do |task_id, deps|
|
|
137
|
+
# task_id depends on each dep, meaning dep must come before task_id
|
|
138
|
+
in_degree_corrected[task_id] += deps.size
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
queue = all_nodes.select { |n| in_degree_corrected[n].zero? }
|
|
142
|
+
sorted = []
|
|
143
|
+
|
|
144
|
+
until queue.empty?
|
|
145
|
+
node = queue.shift
|
|
146
|
+
sorted << node
|
|
147
|
+
|
|
148
|
+
@reverse[node].each do |dependent|
|
|
149
|
+
in_degree_corrected[dependent] -= 1
|
|
150
|
+
queue << dependent if in_degree_corrected[dependent].zero?
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
if sorted.size != all_nodes.size
|
|
155
|
+
raise "Cycle detected in task dependency graph"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
sorted
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def ensure_table
|
|
164
|
+
@db.execute(<<~SQL)
|
|
165
|
+
CREATE TABLE IF NOT EXISTS task_dependencies (
|
|
166
|
+
task_id TEXT NOT NULL,
|
|
167
|
+
depends_on_id TEXT NOT NULL,
|
|
168
|
+
PRIMARY KEY (task_id, depends_on_id),
|
|
169
|
+
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
|
170
|
+
FOREIGN KEY (depends_on_id) REFERENCES tasks(id) ON DELETE CASCADE
|
|
171
|
+
)
|
|
172
|
+
SQL
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def load_from_db
|
|
176
|
+
rows = @db.query("SELECT task_id, depends_on_id FROM task_dependencies").to_a
|
|
177
|
+
rows.each do |row|
|
|
178
|
+
tid = row["task_id"]
|
|
179
|
+
did = row["depends_on_id"]
|
|
180
|
+
@forward[tid].add(did)
|
|
181
|
+
@reverse[did].add(tid)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Checks if +target+ is reachable from +source+ following forward edges.
|
|
186
|
+
def reachable?(source, target)
|
|
187
|
+
visited = Set.new
|
|
188
|
+
stack = [source]
|
|
189
|
+
|
|
190
|
+
until stack.empty?
|
|
191
|
+
node = stack.pop
|
|
192
|
+
next if visited.include?(node)
|
|
193
|
+
|
|
194
|
+
return true if node == target
|
|
195
|
+
|
|
196
|
+
visited.add(node)
|
|
197
|
+
@forward[node].each { |dep| stack << dep }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
false
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def placeholders(count)
|
|
204
|
+
(["?"] * count).join(", ")
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "models"
|
|
5
|
+
require_relative "dag"
|
|
6
|
+
|
|
7
|
+
module RubynCode
|
|
8
|
+
module Tasks
|
|
9
|
+
# CRUD manager for tasks backed by SQLite.
|
|
10
|
+
class Manager
|
|
11
|
+
attr_reader :db
|
|
12
|
+
|
|
13
|
+
# @param db [DB::Connection]
|
|
14
|
+
def initialize(db)
|
|
15
|
+
@db = db
|
|
16
|
+
ensure_table
|
|
17
|
+
@dag = DAG.new(db)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Creates a new task and persists it.
|
|
21
|
+
#
|
|
22
|
+
# @param title [String]
|
|
23
|
+
# @param description [String, nil]
|
|
24
|
+
# @param session_id [String, nil]
|
|
25
|
+
# @param blocked_by [Array<String>] IDs of tasks this one depends on
|
|
26
|
+
# @param priority [Integer]
|
|
27
|
+
# @return [Task]
|
|
28
|
+
def create(title:, description: nil, session_id: nil, blocked_by: [], priority: 0)
|
|
29
|
+
id = SecureRandom.uuid
|
|
30
|
+
now = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
|
|
31
|
+
status = blocked_by.empty? ? "pending" : "blocked"
|
|
32
|
+
|
|
33
|
+
@db.transaction do
|
|
34
|
+
@db.execute(<<~SQL, [id, session_id, title, description, status, priority, now, now])
|
|
35
|
+
INSERT INTO tasks (id, session_id, title, description, status, priority, created_at, updated_at)
|
|
36
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
37
|
+
SQL
|
|
38
|
+
|
|
39
|
+
blocked_by.each do |dep_id|
|
|
40
|
+
@dag.add_dependency(id, dep_id)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
get(id)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Updates arbitrary attributes on a task.
|
|
48
|
+
#
|
|
49
|
+
# @param id [String]
|
|
50
|
+
# @param attrs [Hash] supported keys: status, priority, owner, result, description, title, metadata
|
|
51
|
+
# @return [Task]
|
|
52
|
+
def update(id, **attrs)
|
|
53
|
+
allowed = %i[status priority owner result description title metadata]
|
|
54
|
+
filtered = attrs.select { |k, _| allowed.include?(k) }
|
|
55
|
+
return get(id) if filtered.empty?
|
|
56
|
+
|
|
57
|
+
sets = filtered.map { |k, _| "#{k} = ?" }
|
|
58
|
+
sets << "updated_at = datetime('now')"
|
|
59
|
+
values = filtered.values
|
|
60
|
+
values << id
|
|
61
|
+
|
|
62
|
+
@db.execute(
|
|
63
|
+
"UPDATE tasks SET #{sets.join(', ')} WHERE id = ?",
|
|
64
|
+
values
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
get(id)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Marks a task as completed and cascades unblocking via the DAG.
|
|
71
|
+
#
|
|
72
|
+
# @param id [String]
|
|
73
|
+
# @param result [String, nil]
|
|
74
|
+
# @return [Task]
|
|
75
|
+
def complete(id, result: nil)
|
|
76
|
+
sets = ["status = 'completed'", "updated_at = datetime('now')"]
|
|
77
|
+
values = []
|
|
78
|
+
|
|
79
|
+
if result
|
|
80
|
+
sets << "result = ?"
|
|
81
|
+
values << result
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
values << id
|
|
85
|
+
|
|
86
|
+
@db.execute(
|
|
87
|
+
"UPDATE tasks SET #{sets.join(', ')} WHERE id = ?",
|
|
88
|
+
values
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@dag.unblock_cascade(id)
|
|
92
|
+
|
|
93
|
+
get(id)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Claims a task by setting the owner and moving it to in_progress.
|
|
97
|
+
#
|
|
98
|
+
# @param id [String]
|
|
99
|
+
# @param owner [String]
|
|
100
|
+
# @return [Task]
|
|
101
|
+
def claim(id, owner:)
|
|
102
|
+
@db.execute(
|
|
103
|
+
"UPDATE tasks SET owner = ?, status = 'in_progress', updated_at = datetime('now') WHERE id = ?",
|
|
104
|
+
[owner, id]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
get(id)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Fetches a single task by ID.
|
|
111
|
+
#
|
|
112
|
+
# @param id [String]
|
|
113
|
+
# @return [Task, nil]
|
|
114
|
+
def get(id)
|
|
115
|
+
rows = @db.query("SELECT * FROM tasks WHERE id = ?", [id]).to_a
|
|
116
|
+
row_to_task(rows.first)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Lists tasks with optional filters.
|
|
120
|
+
#
|
|
121
|
+
# @param status [String, nil]
|
|
122
|
+
# @param session_id [String, nil]
|
|
123
|
+
# @return [Array<Task>]
|
|
124
|
+
def list(status: nil, session_id: nil)
|
|
125
|
+
conditions = []
|
|
126
|
+
params = []
|
|
127
|
+
|
|
128
|
+
if status
|
|
129
|
+
conditions << "status = ?"
|
|
130
|
+
params << status
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
if session_id
|
|
134
|
+
conditions << "session_id = ?"
|
|
135
|
+
params << session_id
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
sql = "SELECT * FROM tasks"
|
|
139
|
+
sql += " WHERE #{conditions.join(' AND ')}" unless conditions.empty?
|
|
140
|
+
sql += " ORDER BY priority DESC, created_at ASC"
|
|
141
|
+
|
|
142
|
+
@db.query(sql, params).to_a.filter_map { |row| row_to_task(row) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Returns tasks that are pending, unowned, and have no unmet dependencies.
|
|
146
|
+
#
|
|
147
|
+
# @return [Array<Task>]
|
|
148
|
+
def ready_tasks
|
|
149
|
+
rows = @db.query(
|
|
150
|
+
"SELECT * FROM tasks WHERE status = 'pending' AND owner IS NULL ORDER BY priority DESC, created_at ASC"
|
|
151
|
+
).to_a
|
|
152
|
+
|
|
153
|
+
rows.filter_map { |row| row_to_task(row) }
|
|
154
|
+
.reject { |task| @dag.blocked?(task.id) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Deletes a task and its dependency edges (via CASCADE).
|
|
158
|
+
#
|
|
159
|
+
# @param id [String]
|
|
160
|
+
# @return [void]
|
|
161
|
+
def delete(id)
|
|
162
|
+
@db.execute("DELETE FROM tasks WHERE id = ?", [id])
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def ensure_table
|
|
168
|
+
@db.execute(<<~SQL)
|
|
169
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
170
|
+
id TEXT PRIMARY KEY,
|
|
171
|
+
session_id TEXT,
|
|
172
|
+
title TEXT NOT NULL,
|
|
173
|
+
description TEXT,
|
|
174
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
175
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
176
|
+
owner TEXT,
|
|
177
|
+
result TEXT,
|
|
178
|
+
metadata TEXT,
|
|
179
|
+
created_at TEXT NOT NULL,
|
|
180
|
+
updated_at TEXT NOT NULL
|
|
181
|
+
)
|
|
182
|
+
SQL
|
|
183
|
+
|
|
184
|
+
@db.execute(<<~SQL)
|
|
185
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)
|
|
186
|
+
SQL
|
|
187
|
+
|
|
188
|
+
@db.execute(<<~SQL)
|
|
189
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id)
|
|
190
|
+
SQL
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def row_to_task(row)
|
|
194
|
+
return nil if row.nil?
|
|
195
|
+
|
|
196
|
+
Task.new(
|
|
197
|
+
id: row["id"],
|
|
198
|
+
session_id: row["session_id"],
|
|
199
|
+
title: row["title"],
|
|
200
|
+
description: row["description"],
|
|
201
|
+
status: row["status"],
|
|
202
|
+
priority: row["priority"],
|
|
203
|
+
owner: row["owner"],
|
|
204
|
+
result: row["result"],
|
|
205
|
+
metadata: row["metadata"],
|
|
206
|
+
created_at: row["created_at"],
|
|
207
|
+
updated_at: row["updated_at"]
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Tasks
|
|
5
|
+
Task = Data.define(
|
|
6
|
+
:id, :session_id, :title, :description, :status,
|
|
7
|
+
:priority, :owner, :result, :metadata, :created_at, :updated_at
|
|
8
|
+
) do
|
|
9
|
+
def pending? = status == "pending"
|
|
10
|
+
def in_progress? = status == "in_progress"
|
|
11
|
+
def completed? = status == "completed"
|
|
12
|
+
def blocked? = status == "blocked"
|
|
13
|
+
|
|
14
|
+
def to_h
|
|
15
|
+
{
|
|
16
|
+
id: id,
|
|
17
|
+
session_id: session_id,
|
|
18
|
+
title: title,
|
|
19
|
+
description: description,
|
|
20
|
+
status: status,
|
|
21
|
+
priority: priority,
|
|
22
|
+
owner: owner,
|
|
23
|
+
result: result,
|
|
24
|
+
metadata: metadata,
|
|
25
|
+
created_at: created_at,
|
|
26
|
+
updated_at: updated_at
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Teams
|
|
8
|
+
# JSONL-based mailbox for inter-agent messaging backed by SQLite.
|
|
9
|
+
#
|
|
10
|
+
# Messages are stored in the `mailbox_messages` table with structured
|
|
11
|
+
# JSON content. Each message tracks read/unread state per recipient.
|
|
12
|
+
class Mailbox
|
|
13
|
+
# @param db [DB::Connection] the database connection
|
|
14
|
+
def initialize(db)
|
|
15
|
+
@db = db
|
|
16
|
+
ensure_table!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Sends a message from one agent to another.
|
|
20
|
+
#
|
|
21
|
+
# @param from [String] sender agent name
|
|
22
|
+
# @param to [String] recipient agent name
|
|
23
|
+
# @param content [String] message body
|
|
24
|
+
# @param message_type [String] type of message (default: "message")
|
|
25
|
+
# @return [String] the message id
|
|
26
|
+
def send(from:, to:, content:, message_type: "message")
|
|
27
|
+
id = SecureRandom.uuid
|
|
28
|
+
now = Time.now.utc.iso8601
|
|
29
|
+
|
|
30
|
+
payload = JSON.generate({
|
|
31
|
+
id: id,
|
|
32
|
+
from: from,
|
|
33
|
+
to: to,
|
|
34
|
+
content: content,
|
|
35
|
+
message_type: message_type,
|
|
36
|
+
timestamp: now
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
@db.execute(
|
|
40
|
+
<<~SQL,
|
|
41
|
+
INSERT INTO mailbox_messages (id, sender, recipient, message_type, payload, read, created_at)
|
|
42
|
+
VALUES (?, ?, ?, ?, ?, 0, ?)
|
|
43
|
+
SQL
|
|
44
|
+
[id, from, to, message_type, payload, now]
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
id
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Reads all unread messages for the given agent and marks them as read.
|
|
51
|
+
#
|
|
52
|
+
# @param name [String] the recipient agent name
|
|
53
|
+
# @return [Array<Hash>] parsed message hashes
|
|
54
|
+
def read_inbox(name)
|
|
55
|
+
rows = @db.query(
|
|
56
|
+
<<~SQL,
|
|
57
|
+
SELECT id, payload FROM mailbox_messages
|
|
58
|
+
WHERE recipient = ? AND read = 0
|
|
59
|
+
ORDER BY created_at ASC
|
|
60
|
+
SQL
|
|
61
|
+
[name]
|
|
62
|
+
).to_a
|
|
63
|
+
|
|
64
|
+
return [] if rows.empty?
|
|
65
|
+
|
|
66
|
+
ids = rows.map { |r| r["id"] }
|
|
67
|
+
messages = rows.map { |r| JSON.parse(r["payload"], symbolize_names: true) }
|
|
68
|
+
|
|
69
|
+
# Mark all fetched messages as read in a single statement
|
|
70
|
+
placeholders = ids.map { "?" }.join(", ")
|
|
71
|
+
@db.execute(
|
|
72
|
+
"UPDATE mailbox_messages SET read = 1 WHERE id IN (#{placeholders})",
|
|
73
|
+
ids
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
messages
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Broadcasts a message from one agent to all other agents.
|
|
80
|
+
#
|
|
81
|
+
# @param from [String] sender agent name
|
|
82
|
+
# @param content [String] message body
|
|
83
|
+
# @param all_names [Array<String>] list of all agent names in the team
|
|
84
|
+
# @return [Array<String>] message ids
|
|
85
|
+
def broadcast(from:, content:, all_names:)
|
|
86
|
+
recipients = all_names.reject { |n| n == from }
|
|
87
|
+
|
|
88
|
+
recipients.map do |recipient|
|
|
89
|
+
send(from: from, to: recipient, content: content, message_type: "broadcast")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns the count of unread messages for the given agent.
|
|
94
|
+
#
|
|
95
|
+
# @param name [String] the recipient agent name
|
|
96
|
+
# @return [Integer]
|
|
97
|
+
def unread_count(name)
|
|
98
|
+
rows = @db.query(
|
|
99
|
+
"SELECT COUNT(*) AS cnt FROM mailbox_messages WHERE recipient = ? AND read = 0",
|
|
100
|
+
[name]
|
|
101
|
+
).to_a
|
|
102
|
+
rows.first&.fetch("cnt", 0) || 0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Creates the mailbox_messages table if it does not already exist.
|
|
108
|
+
def ensure_table!
|
|
109
|
+
@db.execute(<<~SQL)
|
|
110
|
+
CREATE TABLE IF NOT EXISTS mailbox_messages (
|
|
111
|
+
id TEXT PRIMARY KEY,
|
|
112
|
+
sender TEXT NOT NULL,
|
|
113
|
+
recipient TEXT NOT NULL,
|
|
114
|
+
message_type TEXT NOT NULL DEFAULT 'message',
|
|
115
|
+
payload TEXT NOT NULL,
|
|
116
|
+
read INTEGER NOT NULL DEFAULT 0,
|
|
117
|
+
created_at TEXT NOT NULL
|
|
118
|
+
)
|
|
119
|
+
SQL
|
|
120
|
+
|
|
121
|
+
@db.execute(<<~SQL)
|
|
122
|
+
CREATE INDEX IF NOT EXISTS idx_mailbox_recipient_read
|
|
123
|
+
ON mailbox_messages (recipient, read)
|
|
124
|
+
SQL
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|