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.
- checksums.yaml +4 -4
- data/.fasterer.yml +10 -0
- data/.loki +33 -0
- data/.reek.yml +161 -0
- data/.rubocop.yml +187 -0
- data/Archspec.rb +45 -0
- data/CHANGELOG.md +52 -1
- data/Gemfile +12 -0
- data/Gemfile.lock +124 -93
- data/README.md +15 -2
- data/_typos.toml +17 -0
- data/docs/api/ruby-library.md +2 -2
- data/docs/cli/admin-commands.md +3 -3
- data/docs/core-concepts/overview.md +2 -2
- data/docs/getting-started/configuration.md +2 -2
- data/docs/getting-started/installation.md +22 -5
- data/docs/index.md +2 -2
- data/docs/mcp/integration.md +11 -8
- data/docs/mcp/overview.md +29 -8
- data/examples/cli_demo.sh +1 -1
- data/examples/mcp/Gemfile.lock +147 -61
- data/lib/trak_flow/cli/admin_commands.rb +77 -59
- data/lib/trak_flow/cli/config_commands.rb +9 -14
- data/lib/trak_flow/cli/dep_commands.rb +3 -3
- data/lib/trak_flow/cli/label_commands.rb +3 -3
- data/lib/trak_flow/cli/main_commands.rb +127 -94
- data/lib/trak_flow/cli/plan_commands.rb +32 -25
- data/lib/trak_flow/cli/workflow_commands.rb +5 -4
- data/lib/trak_flow/cli.rb +2 -2
- data/lib/trak_flow/config/defaults.yml +5 -1
- data/lib/trak_flow/config/section.rb +3 -5
- data/lib/trak_flow/config.rb +20 -34
- data/lib/trak_flow/graph/dependency_graph.rb +72 -69
- data/lib/trak_flow/mcp/resources/dependency_graph.rb +13 -9
- data/lib/trak_flow/mcp/resources/task_by_id.rb +4 -3
- data/lib/trak_flow/mcp/server.rb +77 -76
- data/lib/trak_flow/mcp/tools/task_create.rb +17 -17
- data/lib/trak_flow/models/task.rb +21 -17
- data/lib/trak_flow/storage/database.rb +26 -16
- data/lib/trak_flow/storage/jsonl.rb +60 -62
- data/lib/trak_flow/version.rb +1 -1
- data/lib/trak_flow.rb +0 -1
- data/trak_flow.gemspec +57 -0
- 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
|
|
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(
|
|
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
|
-
|
|
147
|
+
task_id = task.id
|
|
148
|
+
return nil if remaining_depth <= 0 || visited.include?(task_id)
|
|
149
149
|
|
|
150
|
-
visited <<
|
|
150
|
+
visited << task_id
|
|
151
151
|
|
|
152
152
|
node = {
|
|
153
|
-
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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 =>
|
|
259
|
-
1 =>
|
|
260
|
-
2 =>
|
|
261
|
-
3 =>
|
|
261
|
+
0 => priorities[:critical],
|
|
262
|
+
1 => priorities[:high],
|
|
263
|
+
2 => priorities[:medium],
|
|
264
|
+
3 => priorities[:low]
|
|
262
265
|
}
|
|
263
|
-
priority_colors[task.priority] ||
|
|
266
|
+
priority_colors[task.priority] || priorities[:backlog]
|
|
264
267
|
end
|
|
265
268
|
|
|
266
269
|
def edge_style(dep)
|
|
267
|
-
|
|
268
|
-
color = COLORS[:edge][
|
|
270
|
+
type = dep.type
|
|
271
|
+
color = COLORS[:edge][type.tr("-", "_").to_sym]
|
|
269
272
|
return "" unless color
|
|
270
273
|
|
|
271
|
-
style = case
|
|
274
|
+
style = case type
|
|
272
275
|
when "blocks" then "bold"
|
|
273
|
-
when "parent-child"
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
db
|
|
20
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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,
|
data/lib/trak_flow/mcp/server.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
trap("TERM") { launcher.stop }
|
|
101
|
+
private
|
|
94
102
|
|
|
95
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@
|
|
29
|
-
@
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
605
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|