trak_flow 0.1.3 → 0.2.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.fasterer.yml +10 -0
  3. data/.loki +33 -0
  4. data/.reek.yml +161 -0
  5. data/.rubocop.yml +187 -0
  6. data/Archspec.rb +45 -0
  7. data/CHANGELOG.md +52 -1
  8. data/Gemfile +12 -0
  9. data/Gemfile.lock +124 -93
  10. data/README.md +15 -2
  11. data/_typos.toml +17 -0
  12. data/docs/api/ruby-library.md +2 -2
  13. data/docs/cli/admin-commands.md +3 -3
  14. data/docs/core-concepts/overview.md +2 -2
  15. data/docs/getting-started/configuration.md +2 -2
  16. data/docs/getting-started/installation.md +22 -5
  17. data/docs/index.md +2 -2
  18. data/docs/mcp/integration.md +11 -8
  19. data/docs/mcp/overview.md +29 -8
  20. data/examples/cli_demo.sh +1 -1
  21. data/examples/mcp/Gemfile.lock +147 -61
  22. data/lib/trak_flow/cli/admin_commands.rb +77 -59
  23. data/lib/trak_flow/cli/config_commands.rb +9 -14
  24. data/lib/trak_flow/cli/dep_commands.rb +3 -3
  25. data/lib/trak_flow/cli/label_commands.rb +3 -3
  26. data/lib/trak_flow/cli/main_commands.rb +127 -94
  27. data/lib/trak_flow/cli/plan_commands.rb +32 -25
  28. data/lib/trak_flow/cli/workflow_commands.rb +5 -4
  29. data/lib/trak_flow/cli.rb +2 -2
  30. data/lib/trak_flow/config/defaults.yml +5 -1
  31. data/lib/trak_flow/config/section.rb +3 -5
  32. data/lib/trak_flow/config.rb +20 -34
  33. data/lib/trak_flow/graph/dependency_graph.rb +72 -69
  34. data/lib/trak_flow/mcp/resources/dependency_graph.rb +13 -9
  35. data/lib/trak_flow/mcp/resources/task_by_id.rb +4 -3
  36. data/lib/trak_flow/mcp/server.rb +77 -76
  37. data/lib/trak_flow/mcp/tools/task_create.rb +17 -17
  38. data/lib/trak_flow/models/task.rb +21 -17
  39. data/lib/trak_flow/storage/database.rb +26 -16
  40. data/lib/trak_flow/storage/jsonl.rb +60 -62
  41. data/lib/trak_flow/version.rb +1 -1
  42. data/lib/trak_flow.rb +0 -1
  43. data/trak_flow.gemspec +57 -0
  44. metadata +11 -31
@@ -87,30 +87,10 @@ module TrakFlow
87
87
  tasks = task_ids.map { |id| @db.find_task(id) }.compact
88
88
  tasks = tasks.reject(&:closed?) unless include_closed
89
89
 
90
- lines = ["digraph dependencies {"]
91
- lines << ' rankdir=TB;'
92
- lines << ' node [shape=box, style=filled];'
90
+ lines = ["digraph dependencies {", " rankdir=TB;", " node [shape=box, style=filled];", ""]
91
+ lines.concat(dot_node_lines(tasks))
93
92
  lines << ""
94
-
95
- tasks.each do |task|
96
- color = node_color(task)
97
- label = "#{task.id}\\n#{truncate(task.title, 30)}"
98
- lines << " \"#{task.id}\" [label=\"#{label}\", fillcolor=\"#{color}\"];"
99
- end
100
-
101
- lines << ""
102
-
103
- task_set = Set.new(tasks.map(&:id))
104
-
105
- tasks.each do |task|
106
- @db.find_dependencies(task.id, direction: :outgoing).each do |dep|
107
- next unless task_set.include?(dep.target_id)
108
-
109
- style = edge_style(dep)
110
- lines << " \"#{dep.source_id}\" -> \"#{dep.target_id}\" [#{style}];"
111
- end
112
- end
113
-
93
+ lines.concat(dot_edge_lines(tasks))
114
94
  lines << "}"
115
95
  lines.join("\n")
116
96
  end
@@ -127,7 +107,7 @@ module TrakFlow
127
107
  end
128
108
 
129
109
  # Make background transparent for dark theme compatibility
130
- stdout.gsub(/fill="white"/, 'fill="none"')
110
+ stdout.gsub('fill="white"', 'fill="none"')
131
111
  end
132
112
 
133
113
  # Analyze the graph for potential problems
@@ -144,28 +124,40 @@ module TrakFlow
144
124
 
145
125
  private
146
126
 
127
+ def dot_node_lines(tasks)
128
+ tasks.map do |task|
129
+ id = task.id
130
+ label = "#{id}\\n#{truncate(task.title, 30)}"
131
+ " \"#{id}\" [label=\"#{label}\", fillcolor=\"#{node_color(task)}\"];"
132
+ end
133
+ end
134
+
135
+ # Edges are drawn only between tasks that are both in the graph.
136
+ def dot_edge_lines(tasks)
137
+ task_set = Set.new(tasks.map(&:id))
138
+
139
+ tasks.flat_map do |task|
140
+ @db.find_dependencies(task.id, direction: :outgoing)
141
+ .select { |dep| task_set.include?(dep.target_id) }
142
+ .map { |dep| " \"#{dep.source_id}\" -> \"#{dep.target_id}\" [#{edge_style(dep)}];" }
143
+ end
144
+ end
145
+
147
146
  def build_tree_node(task, direction, remaining_depth, visited)
148
- return nil if remaining_depth <= 0 || visited.include?(task.id)
147
+ task_id = task.id
148
+ return nil if remaining_depth <= 0 || visited.include?(task_id)
149
149
 
150
- visited << task.id
150
+ visited << task_id
151
151
 
152
152
  node = {
153
- id: task.id,
153
+ id: task_id,
154
154
  title: task.title,
155
155
  status: task.status,
156
156
  priority: task.priority,
157
157
  children: []
158
158
  }
159
159
 
160
- dep_direction = direction == :blocking ? :incoming : :outgoing
161
- deps = @db.find_dependencies(task.id, direction: dep_direction)
162
- deps = deps.select(&:blocking?) if direction == :blocking
163
-
164
- deps.each do |dep|
165
- related_id = direction == :blocking ? dep.source_id : dep.target_id
166
- related_task = @db.find_task(related_id)
167
- next unless related_task
168
-
160
+ related_tasks(task_id, direction).each do |related_task|
169
161
  child_node = build_tree_node(related_task, direction, remaining_depth - 1, visited.dup)
170
162
  node[:children] << child_node if child_node
171
163
  end
@@ -173,6 +165,16 @@ module TrakFlow
173
165
  node
174
166
  end
175
167
 
168
+ # Tasks one dependency hop away from task_id. In :blocking direction only
169
+ # blocking incoming deps count; otherwise all outgoing deps do.
170
+ def related_tasks(task_id, direction)
171
+ blocking = direction == :blocking
172
+ deps = @db.find_dependencies(task_id, direction: blocking ? :incoming : :outgoing)
173
+ deps = deps.select(&:blocking?) if blocking
174
+
175
+ deps.filter_map { |dep| @db.find_task(blocking ? dep.source_id : dep.target_id) }
176
+ end
177
+
176
178
  def collect_related_tasks(start_id, direction, types)
177
179
  visited = Set.new
178
180
  queue = [start_id]
@@ -184,24 +186,24 @@ module TrakFlow
184
186
 
185
187
  visited << current_id
186
188
 
187
- deps = @db.find_dependencies(current_id, direction: direction)
188
- deps = deps.select { |d| types.include?(d.type) }
189
-
190
- deps.each do |dep|
191
- related_id = direction == :incoming ? dep.source_id : dep.target_id
192
- next if visited.include?(related_id)
193
-
194
- task = @db.find_task(related_id)
195
- if task
196
- result << task
197
- queue << related_id
198
- end
189
+ unvisited_related_tasks(current_id, direction, types, visited).each do |task|
190
+ result << task
191
+ queue << task.id
199
192
  end
200
193
  end
201
194
 
202
195
  result
203
196
  end
204
197
 
198
+ def unvisited_related_tasks(current_id, direction, types, visited)
199
+ @db.find_dependencies(current_id, direction: direction)
200
+ .select { |d| types.include?(d.type) }
201
+ .filter_map do |dep|
202
+ related_id = direction == :incoming ? dep.source_id : dep.target_id
203
+ @db.find_task(related_id) unless visited.include?(related_id)
204
+ end
205
+ end
206
+
205
207
  def find_longest_path(task_id, memo)
206
208
  return memo[task_id] if memo.key?(task_id)
207
209
 
@@ -234,17 +236,17 @@ module TrakFlow
234
236
  bottlenecks = []
235
237
 
236
238
  @db.list_tasks(status: "open").each do |task|
237
- incoming = @db.find_dependencies(task.id, direction: :incoming).count
238
- outgoing = @db.find_dependencies(task.id, direction: :outgoing).count
239
-
240
- if incoming >= 3 || outgoing >= 3
241
- bottlenecks << {
242
- id: task.id,
243
- title: task.title,
244
- incoming_deps: incoming,
245
- outgoing_deps: outgoing
246
- }
247
- end
239
+ id = task.id
240
+ incoming = @db.find_dependencies(id, direction: :incoming).count
241
+ outgoing = @db.find_dependencies(id, direction: :outgoing).count
242
+
243
+ next unless incoming >= 3 || outgoing >= 3
244
+ bottlenecks << {
245
+ id: id,
246
+ title: task.title,
247
+ incoming_deps: incoming,
248
+ outgoing_deps: outgoing
249
+ }
248
250
  end
249
251
 
250
252
  bottlenecks.sort_by { |b| -(b[:incoming_deps] + b[:outgoing_deps]) }
@@ -254,26 +256,27 @@ module TrakFlow
254
256
  status_color = COLORS[:status][task.status.to_sym]
255
257
  return status_color if status_color
256
258
 
259
+ priorities = COLORS[:priority]
257
260
  priority_colors = {
258
- 0 => COLORS[:priority][:critical],
259
- 1 => COLORS[:priority][:high],
260
- 2 => COLORS[:priority][:medium],
261
- 3 => COLORS[:priority][:low]
261
+ 0 => priorities[:critical],
262
+ 1 => priorities[:high],
263
+ 2 => priorities[:medium],
264
+ 3 => priorities[:low]
262
265
  }
263
- priority_colors[task.priority] || COLORS[:priority][:backlog]
266
+ priority_colors[task.priority] || priorities[:backlog]
264
267
  end
265
268
 
266
269
  def edge_style(dep)
267
- type_key = dep.type.tr("-", "_").to_sym
268
- color = COLORS[:edge][type_key]
270
+ type = dep.type
271
+ color = COLORS[:edge][type.tr("-", "_").to_sym]
269
272
  return "" unless color
270
273
 
271
- style = case dep.type
274
+ style = case type
272
275
  when "blocks" then "bold"
273
- when "parent-child", "related", "discovered-from" then "dashed"
276
+ when "parent-child" then "dashed"
277
+ when "related", "discovered-from" then "dotted"
274
278
  else "solid"
275
279
  end
276
- style = "dotted" if %w[related discovered-from].include?(dep.type)
277
280
 
278
281
  %(color="#{color}", style=#{style})
279
282
  end
@@ -12,19 +12,23 @@ module TrakFlow
12
12
  def content
13
13
  self.class.with_database do |db|
14
14
  tasks = db.list_tasks
15
- nodes = tasks.map { |t| { id: t.id, title: t.title, status: t.status } }
16
15
 
17
- edges = []
18
- tasks.each do |task|
19
- db.find_dependencies(task.id, direction: :outgoing).each do |dep|
20
- edges << { source: dep.source_id, target: dep.target_id, type: dep.type }
21
- end
22
- end
23
-
24
- result = { nodes: nodes, edges: edges }
16
+ result = {
17
+ nodes: tasks.map { |t| { id: t.id, title: t.title, status: t.status } },
18
+ edges: edges_for(db, tasks)
19
+ }
25
20
  Oj.dump(result, mode: :compat, indent: 2)
26
21
  end
27
22
  end
23
+
24
+ private
25
+
26
+ def edges_for(db, tasks)
27
+ tasks.flat_map do |task|
28
+ db.find_dependencies(task.id, direction: :outgoing)
29
+ .map { |dep| { source: dep.source_id, target: dep.target_id, type: dep.type } }
30
+ end
31
+ end
28
32
  end
29
33
  end
30
34
  end
@@ -12,9 +12,10 @@ module TrakFlow
12
12
  def content
13
13
  self.class.with_database do |db|
14
14
  task = db.find_task!(params[:id])
15
- labels = db.find_labels(task.id)
16
- deps = db.find_dependencies(task.id)
17
- comments = db.find_comments(task.id)
15
+ task_id = task.id
16
+ labels = db.find_labels(task_id)
17
+ deps = db.find_dependencies(task_id)
18
+ comments = db.find_comments(task_id)
18
19
 
19
20
  result = {
20
21
  task: task.to_h,
@@ -1,10 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fast_mcp"
4
- require "puma"
5
- require "puma/configuration"
6
- require "rack"
7
- require "rackup"
8
4
 
9
5
  module TrakFlow
10
6
  module Mcp
@@ -25,30 +21,16 @@ module TrakFlow
25
21
  mcp_server.start
26
22
  end
27
23
 
28
- def start_http(port: nil)
24
+ def start_http(port: nil, handler: nil)
25
+ require_http_transport_gems
29
26
  port ||= TrakFlow.config.mcp.port
30
27
  puts "Starting TrakFlow MCP Server (HTTP transport on port #{port})..."
31
28
 
32
- rack_app = create_rack_app
33
-
34
- # Configure Puma (supports rack.hijack for SSE)
35
- puma_config = Puma::Configuration.new do |config|
36
- config.bind "tcp://0.0.0.0:#{port}"
37
- config.threads 1, 5
38
- config.workers 0
39
- config.quiet
40
- config.app rack_app
41
- end
42
-
43
- launcher = Puma::Launcher.new(puma_config)
44
-
45
- trap("INT") { launcher.stop }
46
- trap("TERM") { launcher.stop }
47
-
48
- launcher.run
29
+ start_http_server(port, handler)
49
30
  end
50
31
 
51
- def start_both(http_port: nil)
32
+ def start_both(http_port: nil, handler: nil)
33
+ require_http_transport_gems
52
34
  http_port ||= TrakFlow.config.mcp.port
53
35
  puts "Starting TrakFlow MCP Server (dual transport)..."
54
36
  puts " - HTTP: port #{http_port}"
@@ -56,7 +38,7 @@ module TrakFlow
56
38
 
57
39
  # Start HTTP in a thread
58
40
  http_thread = Thread.new do
59
- start_http_server(http_port)
41
+ start_http_server(http_port, handler)
60
42
  end
61
43
 
62
44
  # Run STDIO in main thread (blocking)
@@ -65,9 +47,20 @@ module TrakFlow
65
47
  http_thread.join
66
48
  end
67
49
 
68
- private
50
+ # The MCP transport as a plain Rack application. Public so a host app
51
+ # that already runs its own Rack server can mount it there instead of
52
+ # letting trak_flow boot a second server:
53
+ #
54
+ # # config.ru of the host app
55
+ # map "/mcp" do
56
+ # run TrakFlow::Mcp::Server.new.rack_app
57
+ # end
58
+ #
59
+ # The host's server must support rack.hijack for SSE (Puma, Falcon,
60
+ # WEBrick, iodine all do).
61
+ def rack_app
62
+ require "rack"
69
63
 
70
- def create_rack_app
71
64
  server = mcp_server
72
65
  Rack::Builder.new do
73
66
  use FastMcp::Transports::RackTransport, server
@@ -75,65 +68,73 @@ module TrakFlow
75
68
  end.to_app
76
69
  end
77
70
 
78
- def start_http_server(port)
79
- rack_app = create_rack_app
80
-
81
- # Configure Puma (supports rack.hijack for SSE)
82
- puma_config = Puma::Configuration.new do |config|
83
- config.bind "tcp://0.0.0.0:#{port}"
84
- config.threads 1, 5
85
- config.workers 0
86
- config.quiet
87
- config.app rack_app
88
- end
71
+ # The Rack server that will run the HTTP transport, resolved through
72
+ # the generic Rackup handler interface so any registered server works.
73
+ # Priority: the explicit argument, then TrakFlow.config.mcp.handler
74
+ # (or the TF_MCP__HANDLER env var), then Rackup's default lookup —
75
+ # which picks a server already in the bundle (puma, falcon, webrick).
76
+ def http_handler(handler_name = nil)
77
+ handler_name ||= TrakFlow.config.mcp.handler
78
+ handler_name ? Rackup::Handler.get(handler_name) : Rackup::Handler.default
79
+ rescue LoadError, NameError => e
80
+ raise LoadError,
81
+ "No usable Rack server#{" for handler '#{handler_name}'" if handler_name} found in the bundle. " \
82
+ 'Add one that supports rack.hijack — e.g. `gem "puma"` — to your Gemfile. ' \
83
+ "(#{e.message})"
84
+ end
89
85
 
90
- launcher = Puma::Launcher.new(puma_config)
86
+ # Loads the gems the HTTP transport needs. They are optional
87
+ # dependencies — deliberately NOT in the gemspec, so consumers that
88
+ # only use the models/storage API (or the stdio transport) don't
89
+ # carry HTTP machinery. The Rack *server* is the host application's
90
+ # choice: any bundled server registered with Rackup will do.
91
+ def require_http_transport_gems
92
+ require "rack"
93
+ require "rackup"
94
+ rescue LoadError => e
95
+ raise LoadError,
96
+ "TrakFlow's MCP HTTP transport needs the optional rackup gem plus a Rack " \
97
+ 'server that supports rack.hijack. Add `gem "rackup"` — and `gem "puma"` ' \
98
+ "if your app doesn't already bundle a server — to your Gemfile. (#{e.message})"
99
+ end
91
100
 
92
- trap("INT") { launcher.stop }
93
- trap("TERM") { launcher.stop }
101
+ private
94
102
 
95
- launcher.run
103
+ def start_http_server(port, handler_name = nil)
104
+ http_handler(handler_name).run(rack_app, Host: "0.0.0.0", Port: port)
96
105
  end
97
106
 
107
+ # Every tool the MCP server exposes, grouped by concern.
108
+ TOOLS = [
109
+ # Task management
110
+ Tools::TaskCreate, Tools::TaskUpdate, Tools::TaskClose,
111
+ Tools::TaskStart, Tools::TaskBlock, Tools::TaskDefer,
112
+ # Plan/Workflow
113
+ Tools::PlanCreate, Tools::PlanAddStep, Tools::PlanStart,
114
+ Tools::PlanRun, Tools::WorkflowDiscard, Tools::WorkflowSummarize,
115
+ # Dependencies
116
+ Tools::DepAdd, Tools::DepRemove,
117
+ # Labels
118
+ Tools::LabelAdd, Tools::LabelRemove,
119
+ # Comments
120
+ Tools::CommentAdd
121
+ ].freeze
122
+
123
+ RESOURCES = [
124
+ Resources::TaskList, Resources::TaskById, Resources::TaskNext,
125
+ Resources::PlanList, Resources::PlanById,
126
+ Resources::WorkflowList, Resources::WorkflowById,
127
+ Resources::LabelList, Resources::DependencyGraph
128
+ ].freeze
129
+
130
+ private_constant :TOOLS, :RESOURCES
131
+
98
132
  def register_tools
99
- # Task management tools
100
- mcp_server.register_tool(Tools::TaskCreate)
101
- mcp_server.register_tool(Tools::TaskUpdate)
102
- mcp_server.register_tool(Tools::TaskClose)
103
- mcp_server.register_tool(Tools::TaskStart)
104
- mcp_server.register_tool(Tools::TaskBlock)
105
- mcp_server.register_tool(Tools::TaskDefer)
106
-
107
- # Plan/Workflow tools
108
- mcp_server.register_tool(Tools::PlanCreate)
109
- mcp_server.register_tool(Tools::PlanAddStep)
110
- mcp_server.register_tool(Tools::PlanStart)
111
- mcp_server.register_tool(Tools::PlanRun)
112
- mcp_server.register_tool(Tools::WorkflowDiscard)
113
- mcp_server.register_tool(Tools::WorkflowSummarize)
114
-
115
- # Dependency tools
116
- mcp_server.register_tool(Tools::DepAdd)
117
- mcp_server.register_tool(Tools::DepRemove)
118
-
119
- # Label tools
120
- mcp_server.register_tool(Tools::LabelAdd)
121
- mcp_server.register_tool(Tools::LabelRemove)
122
-
123
- # Comment tool
124
- mcp_server.register_tool(Tools::CommentAdd)
133
+ TOOLS.each { |tool| mcp_server.register_tool(tool) }
125
134
  end
126
135
 
127
136
  def register_resources
128
- mcp_server.register_resource(Resources::TaskList)
129
- mcp_server.register_resource(Resources::TaskById)
130
- mcp_server.register_resource(Resources::TaskNext)
131
- mcp_server.register_resource(Resources::PlanList)
132
- mcp_server.register_resource(Resources::PlanById)
133
- mcp_server.register_resource(Resources::WorkflowList)
134
- mcp_server.register_resource(Resources::WorkflowById)
135
- mcp_server.register_resource(Resources::LabelList)
136
- mcp_server.register_resource(Resources::DependencyGraph)
137
+ RESOURCES.each { |resource| mcp_server.register_resource(resource) }
137
138
  end
138
139
  end
139
140
  end
@@ -20,23 +20,23 @@ module TrakFlow
20
20
  def call(title:, type: "task", priority: 2, description: nil, assignee: nil, parent_id: nil, labels: [])
21
21
  self.class.with_database do |db|
22
22
  task = if parent_id
23
- db.create_child_task(parent_id, {
24
- title: title,
25
- description: description,
26
- type: type,
27
- priority: priority,
28
- assignee: assignee
29
- })
30
- else
31
- new_task = Models::Task.new(
32
- title: title,
33
- description: description,
34
- type: type,
35
- priority: priority,
36
- assignee: assignee
37
- )
38
- db.create_task(new_task)
39
- end
23
+ db.create_child_task(parent_id, {
24
+ title: title,
25
+ description: description,
26
+ type: type,
27
+ priority: priority,
28
+ assignee: assignee
29
+ })
30
+ else
31
+ new_task = Models::Task.new(
32
+ title: title,
33
+ description: description,
34
+ type: type,
35
+ priority: priority,
36
+ assignee: assignee
37
+ )
38
+ db.create_task(new_task)
39
+ end
40
40
 
41
41
  labels.each do |label_name|
42
42
  db.add_label(Models::Label.new(task_id: task.id, name: label_name))
@@ -20,23 +20,27 @@ module TrakFlow
20
20
  VALID_PRIORITIES = TrakFlow::PRIORITIES
21
21
  VALID_TYPES = TrakFlow::TYPES
22
22
 
23
+ # Attributes that default to nil when not given.
24
+ NILABLE_ATTRIBUTES = %i[id title assignee parent_id closed_at content_hash source_plan_id].freeze
25
+
26
+ # Attributes with a non-nil default.
27
+ ATTRIBUTE_DEFAULTS = {
28
+ description: "",
29
+ status: "open",
30
+ priority: 2,
31
+ type: "task",
32
+ plan: false,
33
+ ephemeral: false,
34
+ notes: ""
35
+ }.freeze
36
+
23
37
  def initialize(attrs = {})
24
- @id = attrs[:id]
25
- @title = attrs[:title]
26
- @description = attrs[:description] || ""
27
- @status = attrs[:status] || "open"
28
- @priority = attrs[:priority] || 2
29
- @type = attrs[:type] || "task"
30
- @assignee = attrs[:assignee]
31
- @parent_id = attrs[:parent_id]
32
- @created_at = attrs[:created_at] || Time.now.utc
33
- @updated_at = attrs[:updated_at] || Time.now.utc
34
- @closed_at = attrs[:closed_at]
35
- @content_hash = attrs[:content_hash]
36
- @plan = attrs[:plan] || false
37
- @source_plan_id = attrs[:source_plan_id]
38
- @ephemeral = attrs[:ephemeral] || false
39
- @notes = attrs[:notes] || ""
38
+ NILABLE_ATTRIBUTES.each { |key| instance_variable_set("@#{key}", attrs[key]) }
39
+ ATTRIBUTE_DEFAULTS.each { |key, default| instance_variable_set("@#{key}", attrs[key] || default) }
40
+
41
+ now = Time.now.utc
42
+ @created_at = attrs[:created_at] || now
43
+ @updated_at = attrs[:updated_at] || now
40
44
  end
41
45
 
42
46
  def valid?
@@ -64,7 +68,7 @@ module TrakFlow
64
68
  end
65
69
 
66
70
  def closed?
67
- status == "closed" || status == "tombstone"
71
+ %w[closed tombstone].include?(status)
68
72
  end
69
73
 
70
74
  def in_progress?
@@ -141,13 +141,13 @@ module TrakFlow
141
141
  def find_dependencies(issue_id, direction: :both)
142
142
  deps = []
143
143
 
144
- if direction == :both || direction == :outgoing
144
+ if %i[both outgoing].include?(direction)
145
145
  @db[:dependencies].where(source_id: issue_id).each do |row|
146
146
  deps << Models::Dependency.from_hash(row)
147
147
  end
148
148
  end
149
149
 
150
- if direction == :both || direction == :incoming
150
+ if %i[both incoming].include?(direction)
151
151
  @db[:dependencies].where(target_id: issue_id).each do |row|
152
152
  deps << Models::Dependency.from_hash(row)
153
153
  end
@@ -272,7 +272,7 @@ module TrakFlow
272
272
  end
273
273
 
274
274
  def create_child_task(parent_id, attrs)
275
- parent = find_task!(parent_id)
275
+ find_task!(parent_id)
276
276
  child_count = @db[:tasks].where(parent_id: parent_id).count
277
277
  child_id = IdGenerator.generate_child_id(parent_id, child_count + 1)
278
278
 
@@ -373,6 +373,15 @@ module TrakFlow
373
373
  private
374
374
 
375
375
  def setup_schema
376
+ create_tasks_table
377
+ migrate_schema_if_needed!
378
+ create_dependencies_table
379
+ create_labels_table
380
+ create_comments_table
381
+ create_blocked_tasks_table
382
+ end
383
+
384
+ def create_tasks_table
376
385
  @db.create_table?(:tasks) do
377
386
  String :id, primary_key: true
378
387
  String :title, null: false
@@ -400,9 +409,9 @@ module TrakFlow
400
409
  index :source_plan_id
401
410
  index :ephemeral
402
411
  end
412
+ end
403
413
 
404
- migrate_schema_if_needed!
405
-
414
+ def create_dependencies_table
406
415
  @db.create_table?(:dependencies) do
407
416
  String :id, primary_key: true
408
417
  String :source_id, null: false
@@ -414,7 +423,9 @@ module TrakFlow
414
423
  index :target_id
415
424
  index :type
416
425
  end
426
+ end
417
427
 
428
+ def create_labels_table
418
429
  @db.create_table?(:labels) do
419
430
  String :id, primary_key: true
420
431
  String :task_id, null: false
@@ -425,7 +436,9 @@ module TrakFlow
425
436
  index :name
426
437
  unique %i[task_id name]
427
438
  end
439
+ end
428
440
 
441
+ def create_comments_table
429
442
  @db.create_table?(:comments) do
430
443
  String :id, primary_key: true
431
444
  String :task_id, null: false
@@ -436,7 +449,9 @@ module TrakFlow
436
449
 
437
450
  index :task_id
438
451
  end
452
+ end
439
453
 
454
+ def create_blocked_tasks_table
440
455
  @db.create_table?(:blocked_tasks) do
441
456
  String :task_id, primary_key: true
442
457
 
@@ -498,11 +513,7 @@ module TrakFlow
498
513
  def apply_status_filter(dataset, status)
499
514
  return dataset unless status
500
515
 
501
- if status.is_a?(Array)
502
- dataset.where(status: status)
503
- else
504
- dataset.where(status: status)
505
- end
516
+ dataset.where(status: status)
506
517
  end
507
518
 
508
519
  def apply_priority_filter(dataset, filters)
@@ -601,8 +612,8 @@ module TrakFlow
601
612
 
602
613
  visited << task_id
603
614
  blocking_deps = @db[:dependencies]
604
- .where(target_id: task_id)
605
- .where(type: Models::Dependency::BLOCKING_TYPES)
615
+ .where(target_id: task_id)
616
+ .where(type: Models::Dependency::BLOCKING_TYPES)
606
617
 
607
618
  blocking_deps.any? { |dep| blocker_active?(dep, visited) }
608
619
  end
@@ -628,10 +639,9 @@ module TrakFlow
628
639
  @db.alter_table(:tasks) { add_column :source_plan_id, String } rescue nil
629
640
  @db.add_index :tasks, :source_plan_id rescue nil
630
641
  end
631
- unless columns.include?(:ephemeral)
632
- @db.alter_table(:tasks) { add_column :ephemeral, TrueClass, default: false } rescue nil
633
- @db.add_index :tasks, :ephemeral rescue nil
634
- end
642
+ return if columns.include?(:ephemeral)
643
+ @db.alter_table(:tasks) { add_column :ephemeral, TrueClass, default: false } rescue nil
644
+ @db.add_index :tasks, :ephemeral rescue nil
635
645
  end
636
646
  end
637
647
  end