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
|
@@ -13,14 +13,16 @@ module TrakFlow
|
|
|
13
13
|
option :stealth, type: :boolean, default: false, desc: "Local-only mode without git integration"
|
|
14
14
|
|
|
15
15
|
def init
|
|
16
|
+
trak_dir = TrakFlow.trak_flow_dir
|
|
17
|
+
|
|
16
18
|
if TrakFlow.initialized?
|
|
17
19
|
output({ success: false, error: "TrakFlow already initialized" }) do
|
|
18
|
-
puts pastel.red("Error: TrakFlow already initialized in #{
|
|
20
|
+
puts pastel.red("Error: TrakFlow already initialized in #{trak_dir}")
|
|
19
21
|
end
|
|
20
22
|
return
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
FileUtils.mkdir_p(
|
|
25
|
+
FileUtils.mkdir_p(trak_dir)
|
|
24
26
|
|
|
25
27
|
db = Storage::Database.new
|
|
26
28
|
db.connect
|
|
@@ -32,8 +34,8 @@ module TrakFlow
|
|
|
32
34
|
|
|
33
35
|
setup_gitignore unless options[:stealth]
|
|
34
36
|
|
|
35
|
-
output({ success: true, path:
|
|
36
|
-
puts pastel.green("Initialized TrakFlow in #{
|
|
37
|
+
output({ success: true, path: trak_dir }) do
|
|
38
|
+
puts pastel.green("Initialized TrakFlow in #{trak_dir}")
|
|
37
39
|
end
|
|
38
40
|
end
|
|
39
41
|
|
|
@@ -47,7 +49,7 @@ module TrakFlow
|
|
|
47
49
|
database_path: TrakFlow.database_path,
|
|
48
50
|
jsonl_path: TrakFlow.jsonl_path,
|
|
49
51
|
config_path: TrakFlow.config_path,
|
|
50
|
-
initialized: TrakFlow.initialized
|
|
52
|
+
initialized: TrakFlow.initialized?
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
output(info_data) do
|
|
@@ -71,46 +73,12 @@ module TrakFlow
|
|
|
71
73
|
def create(title)
|
|
72
74
|
validate_option!(:type, TrakFlow::TYPES, options[:type])
|
|
73
75
|
validate_option!(:priority, TrakFlow::PRIORITIES, options[:priority])
|
|
76
|
+
raise ValidationError, "Plans cannot be ephemeral" if options[:plan] && options[:ephemeral]
|
|
74
77
|
|
|
75
78
|
with_database do |db|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
description = options[:body_file] == "-" ? $stdin.read : File.read(options[:body_file])
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
raise ValidationError, "Plans cannot be ephemeral" if options[:plan] && options[:ephemeral]
|
|
83
|
-
|
|
84
|
-
task = if options[:parent]
|
|
85
|
-
db.create_child_task(options[:parent], {
|
|
86
|
-
title: title,
|
|
87
|
-
description: description,
|
|
88
|
-
type: options[:type],
|
|
89
|
-
priority: options[:priority],
|
|
90
|
-
assignee: options[:assignee],
|
|
91
|
-
ephemeral: options[:ephemeral],
|
|
92
|
-
})
|
|
93
|
-
else
|
|
94
|
-
new_task = Models::Task.new(
|
|
95
|
-
title: title,
|
|
96
|
-
description: description,
|
|
97
|
-
type: options[:type],
|
|
98
|
-
priority: options[:priority],
|
|
99
|
-
assignee: options[:assignee],
|
|
100
|
-
plan: options[:plan],
|
|
101
|
-
ephemeral: options[:ephemeral],
|
|
102
|
-
)
|
|
103
|
-
db.create_task(new_task)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
options[:labels]&.each do |label_name|
|
|
107
|
-
db.add_label(Models::Label.new(task_id: task.id, name: label_name))
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
options[:deps]&.each do |dep_spec|
|
|
111
|
-
type, target_id = dep_spec.split(":", 2)
|
|
112
|
-
db.add_dependency(Models::Dependency.new(source_id: task.id, target_id: target_id, type: type))
|
|
113
|
-
end
|
|
79
|
+
task = build_task(db, title, resolve_description)
|
|
80
|
+
attach_labels(db, task)
|
|
81
|
+
attach_dependencies(db, task)
|
|
114
82
|
|
|
115
83
|
output(task.to_h) do
|
|
116
84
|
puts "Created: #{pastel.bold(task.id)} - #{task.title}"
|
|
@@ -147,31 +115,7 @@ module TrakFlow
|
|
|
147
115
|
|
|
148
116
|
def list
|
|
149
117
|
with_database do |db|
|
|
150
|
-
|
|
151
|
-
status: options[:status],
|
|
152
|
-
priority: options[:priority],
|
|
153
|
-
type: options[:type],
|
|
154
|
-
assignee: options[:assignee],
|
|
155
|
-
title_contains: options[:title_contains],
|
|
156
|
-
}.compact
|
|
157
|
-
|
|
158
|
-
tasks = db.list_tasks(filters)
|
|
159
|
-
|
|
160
|
-
if options[:label]
|
|
161
|
-
tasks = tasks.select do |task|
|
|
162
|
-
task_labels = db.find_labels(task.id).map(&:name)
|
|
163
|
-
options[:label].all? { |l| task_labels.include?(l) }
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
if options[:label_any]
|
|
168
|
-
tasks = tasks.select do |task|
|
|
169
|
-
task_labels = db.find_labels(task.id).map(&:name)
|
|
170
|
-
options[:label_any].any? { |l| task_labels.include?(l) }
|
|
171
|
-
end
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
tasks = tasks.take(options[:limit]) if options[:limit]
|
|
118
|
+
tasks = filtered_tasks(db)
|
|
175
119
|
|
|
176
120
|
output(tasks.map(&:to_h)) do
|
|
177
121
|
if tasks.empty?
|
|
@@ -197,13 +141,7 @@ module TrakFlow
|
|
|
197
141
|
|
|
198
142
|
with_database do |db|
|
|
199
143
|
task = db.find_task!(id)
|
|
200
|
-
|
|
201
|
-
task.status = options[:status] if options[:status]
|
|
202
|
-
task.priority = options[:priority] if options[:priority]
|
|
203
|
-
task.title = options[:title] if options[:title]
|
|
204
|
-
task.description = options[:description] if options[:description]
|
|
205
|
-
task.assignee = options[:assignee] if options[:assignee]
|
|
206
|
-
|
|
144
|
+
apply_task_updates(task)
|
|
207
145
|
db.update_task(task)
|
|
208
146
|
|
|
209
147
|
output(task.to_h) do
|
|
@@ -288,17 +226,18 @@ module TrakFlow
|
|
|
288
226
|
def sync
|
|
289
227
|
with_database do |db|
|
|
290
228
|
jsonl = Storage::Jsonl.new
|
|
229
|
+
jsonl_path = jsonl.path
|
|
291
230
|
|
|
292
231
|
if jsonl.exists?
|
|
293
232
|
jsonl.import(db)
|
|
294
|
-
output({ success: true, action: "imported", path:
|
|
295
|
-
puts pastel.green("Imported from #{
|
|
233
|
+
output({ success: true, action: "imported", path: jsonl_path }) do
|
|
234
|
+
puts pastel.green("Imported from #{jsonl_path}")
|
|
296
235
|
end
|
|
297
236
|
end
|
|
298
237
|
|
|
299
238
|
jsonl.export(db)
|
|
300
|
-
output({ success: true, action: "exported", path:
|
|
301
|
-
puts pastel.green("Exported to #{
|
|
239
|
+
output({ success: true, action: "exported", path: jsonl_path }) do
|
|
240
|
+
puts pastel.green("Exported to #{jsonl_path}")
|
|
302
241
|
end
|
|
303
242
|
|
|
304
243
|
unless TrakFlow.config.get("stealth") || TrakFlow.config.get("no_push")
|
|
@@ -328,33 +267,127 @@ module TrakFlow
|
|
|
328
267
|
|
|
329
268
|
private
|
|
330
269
|
|
|
270
|
+
# Reads the task description from --description, --body-file, or stdin ("-").
|
|
271
|
+
def resolve_description
|
|
272
|
+
body_file = options[:body_file]
|
|
273
|
+
return $stdin.read if body_file == "-"
|
|
274
|
+
return File.read(body_file) if body_file
|
|
275
|
+
|
|
276
|
+
options[:description]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Creates the task — as a child of --parent when given, standalone otherwise.
|
|
280
|
+
def build_task(db, title, description)
|
|
281
|
+
attrs = {
|
|
282
|
+
title: title,
|
|
283
|
+
description: description,
|
|
284
|
+
type: options[:type],
|
|
285
|
+
priority: options[:priority],
|
|
286
|
+
assignee: options[:assignee],
|
|
287
|
+
ephemeral: options[:ephemeral]
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if options[:parent]
|
|
291
|
+
db.create_child_task(options[:parent], attrs)
|
|
292
|
+
else
|
|
293
|
+
db.create_task(Models::Task.new(plan: options[:plan], **attrs))
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def attach_labels(db, task)
|
|
298
|
+
options[:labels]&.each do |label_name|
|
|
299
|
+
db.add_label(Models::Label.new(task_id: task.id, name: label_name))
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def attach_dependencies(db, task)
|
|
304
|
+
options[:deps]&.each do |dep_spec|
|
|
305
|
+
type, target_id = dep_spec.split(":", 2)
|
|
306
|
+
db.add_dependency(Models::Dependency.new(source_id: task.id, target_id: target_id, type: type))
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Applies the list command's label/limit options on top of the db filters.
|
|
311
|
+
def filtered_tasks(db)
|
|
312
|
+
tasks = db.list_tasks(list_filters)
|
|
313
|
+
tasks = tasks.select { |task| (options[:label] - task_label_names(db, task)).empty? } if options[:label]
|
|
314
|
+
tasks = tasks.select { |task| options[:label_any].intersect?(task_label_names(db, task)) } if options[:label_any]
|
|
315
|
+
options[:limit] ? tasks.take(options[:limit]) : tasks
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def list_filters
|
|
319
|
+
{
|
|
320
|
+
status: options[:status],
|
|
321
|
+
priority: options[:priority],
|
|
322
|
+
type: options[:type],
|
|
323
|
+
assignee: options[:assignee],
|
|
324
|
+
title_contains: options[:title_contains]
|
|
325
|
+
}.compact
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def task_label_names(db, task)
|
|
329
|
+
db.find_labels(task.id).map(&:name)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Copies each given update option onto the task; untouched fields keep their values.
|
|
333
|
+
def apply_task_updates(task)
|
|
334
|
+
%i[status priority title description assignee].each do |field|
|
|
335
|
+
task.send("#{field}=", options[field]) if options[field]
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
331
339
|
def print_task_details(task, labels, deps, comments)
|
|
340
|
+
print_task_summary(task)
|
|
341
|
+
print_task_body(task, labels)
|
|
342
|
+
print_task_dependencies(task, deps)
|
|
343
|
+
print_task_comments(comments)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def print_task_summary(task)
|
|
332
347
|
puts pastel.bold("Task: #{task.id}")
|
|
333
|
-
puts
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
348
|
+
puts <<~SUMMARY
|
|
349
|
+
Title: #{task.title}
|
|
350
|
+
Status: #{colorize_status(task.status)}
|
|
351
|
+
Priority: #{colorize_priority(task.priority)}
|
|
352
|
+
Type: #{task.type}
|
|
353
|
+
Assignee: #{task.assignee || "unassigned"}
|
|
354
|
+
SUMMARY
|
|
338
355
|
puts "Parent: #{task.parent_id}" if task.parent_id
|
|
339
356
|
puts "Created: #{task.created_at}"
|
|
340
357
|
puts "Updated: #{task.updated_at}"
|
|
341
358
|
puts "Closed: #{task.closed_at}" if task.closed_at
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
puts
|
|
346
|
-
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def print_task_body(task, labels)
|
|
362
|
+
puts <<~BODY
|
|
363
|
+
|
|
364
|
+
Description:
|
|
365
|
+
#{task.description.empty? ? "(none)" : task.description}
|
|
366
|
+
|
|
367
|
+
Labels: #{labels.empty? ? "(none)" : labels.map(&:name).join(", ")}
|
|
368
|
+
BODY
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def print_task_dependencies(task, deps)
|
|
347
372
|
puts ""
|
|
348
373
|
puts "Dependencies:"
|
|
349
374
|
if deps.empty?
|
|
350
375
|
puts " (none)"
|
|
351
376
|
else
|
|
352
|
-
deps.each
|
|
353
|
-
direction = dep.source_id == task.id ? "->" : "<-"
|
|
354
|
-
other_id = dep.source_id == task.id ? dep.target_id : dep.source_id
|
|
355
|
-
puts " #{direction} #{other_id} (#{dep.type})"
|
|
356
|
-
end
|
|
377
|
+
deps.each { |dep| puts " #{format_dependency(task, dep)}" }
|
|
357
378
|
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Renders one dependency relative to the task being shown:
|
|
382
|
+
# "-> other (type)" when the task is the source, "<- other (type)" otherwise.
|
|
383
|
+
def format_dependency(task, dep)
|
|
384
|
+
outgoing = dep.source_id == task.id
|
|
385
|
+
direction = outgoing ? "->" : "<-"
|
|
386
|
+
other_id = outgoing ? dep.target_id : dep.source_id
|
|
387
|
+
"#{direction} #{other_id} (#{dep.type})"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def print_task_comments(comments)
|
|
358
391
|
puts ""
|
|
359
392
|
puts "Comments: #{comments.size}"
|
|
360
393
|
comments.each do |comment|
|
|
@@ -132,28 +132,8 @@ module TrakFlow
|
|
|
132
132
|
raise Error, "#{plan_id} is not a Plan" unless plan.plan?
|
|
133
133
|
|
|
134
134
|
vars = options[:var] || {}
|
|
135
|
-
|
|
136
|
-
workflow
|
|
137
|
-
title: interpolate_vars(plan.title, vars),
|
|
138
|
-
description: interpolate_vars(plan.description, vars),
|
|
139
|
-
type: plan.type,
|
|
140
|
-
priority: plan.priority,
|
|
141
|
-
source_plan_id: plan.id,
|
|
142
|
-
ephemeral: ephemeral
|
|
143
|
-
)
|
|
144
|
-
db.create_task(workflow)
|
|
145
|
-
workflow.append_trace("INSTANTIATED", "from Plan #{plan.id}")
|
|
146
|
-
db.update_task(workflow)
|
|
147
|
-
|
|
148
|
-
db.find_plan_tasks(plan_id).each do |step|
|
|
149
|
-
db.create_child_task(workflow.id, {
|
|
150
|
-
title: interpolate_vars(step.title, vars),
|
|
151
|
-
description: interpolate_vars(step.description, vars),
|
|
152
|
-
type: step.type,
|
|
153
|
-
priority: step.priority,
|
|
154
|
-
ephemeral: ephemeral
|
|
155
|
-
})
|
|
156
|
-
end
|
|
135
|
+
workflow = create_workflow_from_plan(db, plan, vars, ephemeral)
|
|
136
|
+
instantiate_plan_steps(db, plan_id, workflow.id, vars, ephemeral)
|
|
157
137
|
|
|
158
138
|
mode = ephemeral ? "ephemeral" : "persistent"
|
|
159
139
|
output(workflow.to_h) do
|
|
@@ -162,6 +142,33 @@ module TrakFlow
|
|
|
162
142
|
end
|
|
163
143
|
end
|
|
164
144
|
|
|
145
|
+
def create_workflow_from_plan(db, plan, vars, ephemeral)
|
|
146
|
+
workflow = Models::Task.new(
|
|
147
|
+
title: interpolate_vars(plan.title, vars),
|
|
148
|
+
description: interpolate_vars(plan.description, vars),
|
|
149
|
+
type: plan.type,
|
|
150
|
+
priority: plan.priority,
|
|
151
|
+
source_plan_id: plan.id,
|
|
152
|
+
ephemeral: ephemeral
|
|
153
|
+
)
|
|
154
|
+
db.create_task(workflow)
|
|
155
|
+
workflow.append_trace("INSTANTIATED", "from Plan #{plan.id}")
|
|
156
|
+
db.update_task(workflow)
|
|
157
|
+
workflow
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def instantiate_plan_steps(db, plan_id, workflow_id, vars, ephemeral)
|
|
161
|
+
db.find_plan_tasks(plan_id).each do |step|
|
|
162
|
+
db.create_child_task(workflow_id, {
|
|
163
|
+
title: interpolate_vars(step.title, vars),
|
|
164
|
+
description: interpolate_vars(step.description, vars),
|
|
165
|
+
type: step.type,
|
|
166
|
+
priority: step.priority,
|
|
167
|
+
ephemeral: ephemeral
|
|
168
|
+
})
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
165
172
|
def interpolate_vars(text, vars)
|
|
166
173
|
return text if text.nil? || vars.empty?
|
|
167
174
|
|
|
@@ -171,13 +178,13 @@ module TrakFlow
|
|
|
171
178
|
end
|
|
172
179
|
|
|
173
180
|
# Delegate helper methods to parent CLI
|
|
174
|
-
def with_database(&
|
|
181
|
+
def with_database(&) = CLI.new.with_database(&)
|
|
175
182
|
|
|
176
|
-
def output(json_data
|
|
183
|
+
def output(json_data)
|
|
177
184
|
if options[:json]
|
|
178
185
|
puts Oj.dump(json_data, mode: :compat, indent: 2)
|
|
179
186
|
else
|
|
180
|
-
|
|
187
|
+
yield
|
|
181
188
|
end
|
|
182
189
|
end
|
|
183
190
|
end
|
|
@@ -72,7 +72,8 @@ module TrakFlow
|
|
|
72
72
|
with_database do |db|
|
|
73
73
|
workflow = db.find_task!(id)
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
summary = options[:summary]
|
|
76
|
+
summary_text = File.exist?(summary) ? File.read(summary) : summary
|
|
76
77
|
|
|
77
78
|
workflow.notes = "#{workflow.notes}\n\n[Summary]\n#{summary_text}".strip
|
|
78
79
|
workflow.close!(reason: "summarized")
|
|
@@ -118,14 +119,14 @@ module TrakFlow
|
|
|
118
119
|
end
|
|
119
120
|
|
|
120
121
|
# Delegate helper methods to parent CLI
|
|
121
|
-
def with_database(&
|
|
122
|
+
def with_database(&) = CLI.new.with_database(&)
|
|
122
123
|
def status_icon(status) = CLI.new.status_icon(status)
|
|
123
124
|
|
|
124
|
-
def output(json_data
|
|
125
|
+
def output(json_data)
|
|
125
126
|
if options[:json]
|
|
126
127
|
puts Oj.dump(json_data, mode: :compat, indent: 2)
|
|
127
128
|
else
|
|
128
|
-
|
|
129
|
+
yield
|
|
129
130
|
end
|
|
130
131
|
end
|
|
131
132
|
end
|
data/lib/trak_flow/cli.rb
CHANGED
|
@@ -102,10 +102,14 @@ defaults:
|
|
|
102
102
|
|
|
103
103
|
# ---------------------------------------------------------------------------
|
|
104
104
|
# MCP Server Configuration
|
|
105
|
-
# Access: TrakFlow.config.mcp.port
|
|
105
|
+
# Access: TrakFlow.config.mcp.port, TrakFlow.config.mcp.handler
|
|
106
106
|
# ---------------------------------------------------------------------------
|
|
107
107
|
mcp:
|
|
108
108
|
port: 3333
|
|
109
|
+
# Rack server for the HTTP transport, by Rackup handler name (e.g.
|
|
110
|
+
# "puma", "falcon", "webrick"). Null lets Rackup pick whatever server
|
|
111
|
+
# the bundle already carries. The server must support rack.hijack.
|
|
112
|
+
handler: ~
|
|
109
113
|
|
|
110
114
|
# ---------------------------------------------------------------------------
|
|
111
115
|
# Actor Configuration
|
|
@@ -16,14 +16,12 @@ module TrakFlow
|
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def method_missing(method, *args, &
|
|
19
|
+
def method_missing(method, *args, &)
|
|
20
20
|
key = method.to_s
|
|
21
21
|
if key.end_with?('=')
|
|
22
22
|
@data[key.chomp('=').to_sym] = args.first
|
|
23
23
|
elsif @data.key?(method)
|
|
24
24
|
@data[method]
|
|
25
|
-
else
|
|
26
|
-
nil
|
|
27
25
|
end
|
|
28
26
|
end
|
|
29
27
|
|
|
@@ -55,8 +53,8 @@ module TrakFlow
|
|
|
55
53
|
@data.keys
|
|
56
54
|
end
|
|
57
55
|
|
|
58
|
-
def each(&
|
|
59
|
-
@data.each(&
|
|
56
|
+
def each(&)
|
|
57
|
+
@data.each(&)
|
|
60
58
|
end
|
|
61
59
|
|
|
62
60
|
private
|
data/lib/trak_flow/config.rb
CHANGED
|
@@ -53,11 +53,13 @@ module TrakFlow
|
|
|
53
53
|
SCHEMA = raw_yaml[:defaults] || {}
|
|
54
54
|
rescue StandardError => e
|
|
55
55
|
raise TrakFlow::ConfigurationError,
|
|
56
|
-
|
|
56
|
+
"Could not load schema from #{DEFAULTS_PATH}: #{e.message}"
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
# Nested section attributes (defined as hashes, converted to ConfigSection)
|
|
60
|
-
|
|
60
|
+
SECTION_KEYS = %i[output daemon sync create validation id import export storage database mcp].freeze
|
|
61
|
+
|
|
62
|
+
attr_config(*SECTION_KEYS)
|
|
61
63
|
|
|
62
64
|
# Top-level scalar attributes
|
|
63
65
|
attr_config :actor
|
|
@@ -68,7 +70,7 @@ module TrakFlow
|
|
|
68
70
|
|
|
69
71
|
def self.config_section_with_defaults(section_key)
|
|
70
72
|
defaults = SCHEMA[section_key] || {}
|
|
71
|
-
|
|
73
|
+
lambda { |v|
|
|
72
74
|
return v if v.is_a?(ConfigSection)
|
|
73
75
|
incoming = v || {}
|
|
74
76
|
merged = deep_merge_hashes(defaults.dup, incoming)
|
|
@@ -86,19 +88,7 @@ module TrakFlow
|
|
|
86
88
|
end
|
|
87
89
|
end
|
|
88
90
|
|
|
89
|
-
coerce_types(
|
|
90
|
-
output: config_section_with_defaults(:output),
|
|
91
|
-
daemon: config_section_with_defaults(:daemon),
|
|
92
|
-
sync: config_section_with_defaults(:sync),
|
|
93
|
-
create: config_section_with_defaults(:create),
|
|
94
|
-
validation: config_section_with_defaults(:validation),
|
|
95
|
-
id: config_section_with_defaults(:id),
|
|
96
|
-
import: config_section_with_defaults(:import),
|
|
97
|
-
export: config_section_with_defaults(:export),
|
|
98
|
-
storage: config_section_with_defaults(:storage),
|
|
99
|
-
database: config_section_with_defaults(:database),
|
|
100
|
-
mcp: config_section_with_defaults(:mcp)
|
|
101
|
-
)
|
|
91
|
+
coerce_types(SECTION_KEYS.to_h { |key| [key, config_section_with_defaults(key)] })
|
|
102
92
|
|
|
103
93
|
on_load :setup_defaults
|
|
104
94
|
|
|
@@ -229,13 +219,12 @@ module TrakFlow
|
|
|
229
219
|
mapping = LEGACY_KEY_MAP[key.to_s]
|
|
230
220
|
return unless mapping
|
|
231
221
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
end
|
|
222
|
+
return unless mapping.is_a?(Array)
|
|
223
|
+
if mapping.length == 1
|
|
224
|
+
send("#{mapping[0]}=", value)
|
|
225
|
+
else
|
|
226
|
+
section = send(mapping[0])
|
|
227
|
+
section.send("#{mapping[1]}=", value)
|
|
239
228
|
end
|
|
240
229
|
end
|
|
241
230
|
|
|
@@ -244,19 +233,16 @@ module TrakFlow
|
|
|
244
233
|
def setup_defaults
|
|
245
234
|
# Ensure all sections are initialized with defaults even when no config files exist
|
|
246
235
|
# Manually apply coercion since it only fires when values come from config sources
|
|
247
|
-
|
|
248
|
-
self.daemon = self.class.config_section_with_defaults(:daemon).call(daemon) unless daemon.is_a?(ConfigSection)
|
|
249
|
-
self.sync = self.class.config_section_with_defaults(:sync).call(sync) unless sync.is_a?(ConfigSection)
|
|
250
|
-
self.create = self.class.config_section_with_defaults(:create).call(create) unless create.is_a?(ConfigSection)
|
|
251
|
-
self.validation = self.class.config_section_with_defaults(:validation).call(validation) unless validation.is_a?(ConfigSection)
|
|
252
|
-
self.id = self.class.config_section_with_defaults(:id).call(self.id) unless self.id.is_a?(ConfigSection)
|
|
253
|
-
self.import = self.class.config_section_with_defaults(:import).call(self.import) unless self.import.is_a?(ConfigSection)
|
|
254
|
-
self.export = self.class.config_section_with_defaults(:export).call(self.export) unless self.export.is_a?(ConfigSection)
|
|
255
|
-
self.storage = self.class.config_section_with_defaults(:storage).call(self.storage) unless self.storage.is_a?(ConfigSection)
|
|
256
|
-
self.database = self.class.config_section_with_defaults(:database).call(self.database) unless self.database.is_a?(ConfigSection)
|
|
257
|
-
self.mcp = self.class.config_section_with_defaults(:mcp).call(self.mcp) unless self.mcp.is_a?(ConfigSection)
|
|
236
|
+
SECTION_KEYS.each { |key| ensure_section!(key) }
|
|
258
237
|
self.actor ||= ENV.fetch('USER', 'unknown')
|
|
259
238
|
end
|
|
239
|
+
|
|
240
|
+
def ensure_section!(key)
|
|
241
|
+
current = send(key)
|
|
242
|
+
return if current.is_a?(ConfigSection)
|
|
243
|
+
|
|
244
|
+
send("#{key}=", self.class.config_section_with_defaults(key).call(current))
|
|
245
|
+
end
|
|
260
246
|
end
|
|
261
247
|
|
|
262
248
|
class << self
|