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.
Files changed (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. 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