trak_flow 0.1.3

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 (95) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +3 -0
  3. data/CHANGELOG.md +69 -0
  4. data/COMMITS.md +196 -0
  5. data/Gemfile +8 -0
  6. data/Gemfile.lock +281 -0
  7. data/README.md +479 -0
  8. data/Rakefile +16 -0
  9. data/bin/tf +6 -0
  10. data/bin/tf_mcp +81 -0
  11. data/docs/.keep +0 -0
  12. data/docs/api/database.md +434 -0
  13. data/docs/api/ruby-library.md +349 -0
  14. data/docs/api/task-model.md +341 -0
  15. data/docs/assets/stylesheets/extra.css +53 -0
  16. data/docs/assets/trak_flow.jpg +0 -0
  17. data/docs/cli/admin-commands.md +369 -0
  18. data/docs/cli/dependency-commands.md +321 -0
  19. data/docs/cli/label-commands.md +222 -0
  20. data/docs/cli/overview.md +163 -0
  21. data/docs/cli/plan-commands.md +344 -0
  22. data/docs/cli/task-commands.md +333 -0
  23. data/docs/core-concepts/dependencies.md +232 -0
  24. data/docs/core-concepts/labels.md +217 -0
  25. data/docs/core-concepts/overview.md +178 -0
  26. data/docs/core-concepts/plans-workflows.md +264 -0
  27. data/docs/core-concepts/tasks.md +205 -0
  28. data/docs/getting-started/configuration.md +120 -0
  29. data/docs/getting-started/installation.md +79 -0
  30. data/docs/getting-started/quick-start.md +245 -0
  31. data/docs/index.md +169 -0
  32. data/docs/mcp/integration.md +302 -0
  33. data/docs/mcp/overview.md +206 -0
  34. data/docs/mcp/resources.md +284 -0
  35. data/docs/mcp/tools.md +457 -0
  36. data/examples/basic_usage.rb +365 -0
  37. data/examples/cli_demo.sh +314 -0
  38. data/examples/mcp/Gemfile +9 -0
  39. data/examples/mcp/Gemfile.lock +226 -0
  40. data/examples/mcp/http_demo.rb +232 -0
  41. data/examples/mcp/stdio_demo.rb +146 -0
  42. data/lib/trak_flow/cli/admin_commands.rb +136 -0
  43. data/lib/trak_flow/cli/config_commands.rb +260 -0
  44. data/lib/trak_flow/cli/dep_commands.rb +71 -0
  45. data/lib/trak_flow/cli/label_commands.rb +76 -0
  46. data/lib/trak_flow/cli/main_commands.rb +386 -0
  47. data/lib/trak_flow/cli/plan_commands.rb +185 -0
  48. data/lib/trak_flow/cli/workflow_commands.rb +133 -0
  49. data/lib/trak_flow/cli.rb +110 -0
  50. data/lib/trak_flow/config/defaults.yml +114 -0
  51. data/lib/trak_flow/config/section.rb +74 -0
  52. data/lib/trak_flow/config.rb +276 -0
  53. data/lib/trak_flow/graph/dependency_graph.rb +288 -0
  54. data/lib/trak_flow/id_generator.rb +52 -0
  55. data/lib/trak_flow/mcp/resources/base_resource.rb +25 -0
  56. data/lib/trak_flow/mcp/resources/dependency_graph.rb +31 -0
  57. data/lib/trak_flow/mcp/resources/label_list.rb +21 -0
  58. data/lib/trak_flow/mcp/resources/plan_by_id.rb +27 -0
  59. data/lib/trak_flow/mcp/resources/plan_list.rb +21 -0
  60. data/lib/trak_flow/mcp/resources/task_by_id.rb +31 -0
  61. data/lib/trak_flow/mcp/resources/task_list.rb +21 -0
  62. data/lib/trak_flow/mcp/resources/task_next.rb +30 -0
  63. data/lib/trak_flow/mcp/resources/workflow_by_id.rb +27 -0
  64. data/lib/trak_flow/mcp/resources/workflow_list.rb +21 -0
  65. data/lib/trak_flow/mcp/server.rb +140 -0
  66. data/lib/trak_flow/mcp/tools/base_tool.rb +29 -0
  67. data/lib/trak_flow/mcp/tools/comment_add.rb +33 -0
  68. data/lib/trak_flow/mcp/tools/dep_add.rb +34 -0
  69. data/lib/trak_flow/mcp/tools/dep_remove.rb +25 -0
  70. data/lib/trak_flow/mcp/tools/label_add.rb +28 -0
  71. data/lib/trak_flow/mcp/tools/label_remove.rb +25 -0
  72. data/lib/trak_flow/mcp/tools/plan_add_step.rb +35 -0
  73. data/lib/trak_flow/mcp/tools/plan_create.rb +33 -0
  74. data/lib/trak_flow/mcp/tools/plan_run.rb +58 -0
  75. data/lib/trak_flow/mcp/tools/plan_start.rb +58 -0
  76. data/lib/trak_flow/mcp/tools/task_block.rb +27 -0
  77. data/lib/trak_flow/mcp/tools/task_close.rb +26 -0
  78. data/lib/trak_flow/mcp/tools/task_create.rb +51 -0
  79. data/lib/trak_flow/mcp/tools/task_defer.rb +27 -0
  80. data/lib/trak_flow/mcp/tools/task_start.rb +25 -0
  81. data/lib/trak_flow/mcp/tools/task_update.rb +36 -0
  82. data/lib/trak_flow/mcp/tools/workflow_discard.rb +28 -0
  83. data/lib/trak_flow/mcp/tools/workflow_summarize.rb +34 -0
  84. data/lib/trak_flow/mcp.rb +38 -0
  85. data/lib/trak_flow/models/comment.rb +71 -0
  86. data/lib/trak_flow/models/dependency.rb +96 -0
  87. data/lib/trak_flow/models/label.rb +90 -0
  88. data/lib/trak_flow/models/task.rb +188 -0
  89. data/lib/trak_flow/storage/database.rb +638 -0
  90. data/lib/trak_flow/storage/jsonl.rb +259 -0
  91. data/lib/trak_flow/time_parser.rb +15 -0
  92. data/lib/trak_flow/version.rb +5 -0
  93. data/lib/trak_flow.rb +100 -0
  94. data/mkdocs.yml +143 -0
  95. metadata +392 -0
@@ -0,0 +1,638 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrakFlow
4
+ module Storage
5
+ # SQLite database layer for fast local queries
6
+ # This is the gitignored working copy that provides millisecond response times
7
+ class Database
8
+ attr_reader :db
9
+
10
+ def initialize(db_path = nil)
11
+ @db_path = db_path || TrakFlow.database_path
12
+ @db = nil
13
+ @dirty = false
14
+ end
15
+
16
+ def connect
17
+ @db = Sequel.sqlite(@db_path)
18
+ setup_schema
19
+ self
20
+ end
21
+
22
+ def close
23
+ @db&.disconnect
24
+ @db = nil
25
+ end
26
+
27
+ def connected?
28
+ !@db.nil?
29
+ end
30
+
31
+ def dirty?
32
+ @dirty
33
+ end
34
+
35
+ def mark_dirty!
36
+ @dirty = true
37
+ end
38
+
39
+ def mark_clean!
40
+ @dirty = false
41
+ end
42
+
43
+ # Task operations
44
+
45
+ def create_task(task)
46
+ task.validate!
47
+ existing_ids = @db[:tasks].select_map(:id)
48
+ task.id ||= IdGenerator.generate(existing_ids: existing_ids)
49
+ task.update_content_hash!
50
+
51
+ @db[:tasks].insert(task_to_row(task))
52
+ mark_dirty!
53
+ task
54
+ end
55
+
56
+ def find_task(id)
57
+ row = @db[:tasks].where(id: id).first
58
+ return nil unless row
59
+
60
+ Models::Task.from_hash(row)
61
+ end
62
+
63
+ def find_task!(id)
64
+ task = find_task(id)
65
+ raise TaskNotFoundError, "Task not found: #{id}" unless task
66
+
67
+ task
68
+ end
69
+
70
+ def update_task(task)
71
+ task.validate!
72
+ task.touch!
73
+
74
+ @db[:tasks].where(id: task.id).update(task_to_row(task))
75
+ mark_dirty!
76
+ task
77
+ end
78
+
79
+ def delete_task(id)
80
+ @db[:tasks].where(id: id).delete
81
+ @db[:labels].where(task_id: id).delete
82
+ @db[:dependencies].where(source_id: id).or(target_id: id).delete
83
+ @db[:comments].where(task_id: id).delete
84
+ mark_dirty!
85
+ end
86
+
87
+ def list_tasks(filters = {})
88
+ dataset = @db[:tasks]
89
+
90
+ dataset = apply_status_filter(dataset, filters[:status])
91
+ dataset = apply_priority_filter(dataset, filters)
92
+ dataset = apply_type_filter(dataset, filters[:type])
93
+ dataset = apply_assignee_filter(dataset, filters[:assignee])
94
+ dataset = apply_text_filters(dataset, filters)
95
+ dataset = apply_date_filters(dataset, filters)
96
+ dataset = apply_null_filters(dataset, filters)
97
+ dataset = dataset.where(ephemeral: false) unless filters[:include_ephemeral]
98
+ dataset = dataset.where(plan: false) unless filters[:include_plans]
99
+ dataset = dataset.exclude(status: "tombstone") unless filters[:include_tombstones]
100
+
101
+ dataset.order(Sequel.asc(:priority), Sequel.desc(:updated_at)).map do |row|
102
+ Models::Task.from_hash(row)
103
+ end
104
+ end
105
+
106
+ def all_task_ids
107
+ @db[:tasks].select_map(:id)
108
+ end
109
+
110
+ # Dependency operations
111
+
112
+ def add_dependency(dependency)
113
+ dependency.validate!
114
+ detect_cycle!(dependency)
115
+
116
+ # Check if dependency already exists (idempotent operation)
117
+ existing = @db[:dependencies].where(
118
+ source_id: dependency.source_id,
119
+ target_id: dependency.target_id,
120
+ type: dependency.type
121
+ ).first
122
+
123
+ return dependency if existing
124
+
125
+ @db[:dependencies].insert(dependency_to_row(dependency))
126
+ rebuild_blocked_cache!
127
+ mark_dirty!
128
+ dependency
129
+ end
130
+
131
+ def remove_dependency(source_id, target_id, type: nil)
132
+ dataset = @db[:dependencies].where(source_id: source_id, target_id: target_id)
133
+ dataset = dataset.where(type: type) if type
134
+
135
+ deleted = dataset.delete
136
+ rebuild_blocked_cache! if deleted.positive?
137
+ mark_dirty! if deleted.positive?
138
+ deleted
139
+ end
140
+
141
+ def find_dependencies(issue_id, direction: :both)
142
+ deps = []
143
+
144
+ if direction == :both || direction == :outgoing
145
+ @db[:dependencies].where(source_id: issue_id).each do |row|
146
+ deps << Models::Dependency.from_hash(row)
147
+ end
148
+ end
149
+
150
+ if direction == :both || direction == :incoming
151
+ @db[:dependencies].where(target_id: issue_id).each do |row|
152
+ deps << Models::Dependency.from_hash(row)
153
+ end
154
+ end
155
+
156
+ deps
157
+ end
158
+
159
+ def blocking_dependencies(issue_id)
160
+ @db[:dependencies]
161
+ .where(target_id: issue_id)
162
+ .where(type: Models::Dependency::BLOCKING_TYPES)
163
+ .map { |row| Models::Dependency.from_hash(row) }
164
+ end
165
+
166
+ # Label operations
167
+
168
+ def add_label(label)
169
+ label.validate!
170
+
171
+ existing = @db[:labels].where(task_id: label.task_id, name: label.name).first
172
+ return Models::Label.from_hash(existing) if existing
173
+
174
+ @db[:labels].insert(label_to_row(label))
175
+ mark_dirty!
176
+ label
177
+ end
178
+
179
+ def remove_label(task_id, name)
180
+ deleted = @db[:labels].where(task_id: task_id, name: name).delete
181
+ mark_dirty! if deleted.positive?
182
+ deleted
183
+ end
184
+
185
+ def find_labels(task_id)
186
+ @db[:labels].where(task_id: task_id).map do |row|
187
+ Models::Label.from_hash(row)
188
+ end
189
+ end
190
+
191
+ def all_labels
192
+ @db[:labels].distinct.select_map(:name).sort
193
+ end
194
+
195
+ def set_state(task_id, dimension, value, reason: nil)
196
+ prefix = "#{dimension}:"
197
+ @db[:labels].where(task_id: task_id).where(Sequel.like(:name, "#{prefix}%")).delete
198
+
199
+ label = Models::Label.new(task_id: task_id, name: "#{dimension}:#{value}")
200
+ add_label(label)
201
+
202
+ if reason
203
+ task = find_task!(task_id)
204
+ task.notes = "#{task.notes}\n[State] #{dimension}=#{value}: #{reason}".strip
205
+ update_task(task)
206
+ end
207
+
208
+ label
209
+ end
210
+
211
+ def get_state(task_id, dimension)
212
+ label = @db[:labels].where(task_id: task_id).where(Sequel.like(:name, "#{dimension}:%")).first
213
+ return nil unless label
214
+
215
+ label[:name].split(":", 2).last
216
+ end
217
+
218
+ # Comment operations
219
+
220
+ def add_comment(comment)
221
+ comment.validate!
222
+ @db[:comments].insert(comment_to_row(comment))
223
+ mark_dirty!
224
+ comment
225
+ end
226
+
227
+ def find_comments(task_id)
228
+ @db[:comments].where(task_id: task_id).order(:created_at).map do |row|
229
+ Models::Comment.from_hash(row)
230
+ end
231
+ end
232
+
233
+ # Ready work detection
234
+
235
+ def ready_tasks
236
+ blocked_ids = @db[:blocked_tasks].select_map(:task_id)
237
+
238
+ @db[:tasks]
239
+ .where(status: "open")
240
+ .where(ephemeral: false)
241
+ .where(plan: false)
242
+ .exclude(id: blocked_ids)
243
+ .order(Sequel.asc(:priority), Sequel.desc(:updated_at))
244
+ .map { |row| Models::Task.from_hash(row) }
245
+ end
246
+
247
+ def blocked_tasks
248
+ blocked_ids = @db[:blocked_tasks].select_map(:task_id)
249
+
250
+ @db[:tasks]
251
+ .where(id: blocked_ids)
252
+ .where(ephemeral: false)
253
+ .where(plan: false)
254
+ .map { |row| Models::Task.from_hash(row) }
255
+ end
256
+
257
+ # Stale tasks
258
+
259
+ def stale_tasks(days: 30, status: nil)
260
+ cutoff = Time.now.utc - (days * 24 * 60 * 60)
261
+ dataset = @db[:tasks].where(ephemeral: false).where(plan: false).where { updated_at < cutoff }
262
+ dataset = dataset.where(status: status) if status
263
+ dataset.order(:updated_at).map { |row| Models::Task.from_hash(row) }
264
+ end
265
+
266
+ # Child tasks (for epics)
267
+
268
+ def child_tasks(parent_id)
269
+ @db[:tasks].where(parent_id: parent_id).map do |row|
270
+ Models::Task.from_hash(row)
271
+ end
272
+ end
273
+
274
+ def create_child_task(parent_id, attrs)
275
+ parent = find_task!(parent_id)
276
+ child_count = @db[:tasks].where(parent_id: parent_id).count
277
+ child_id = IdGenerator.generate_child_id(parent_id, child_count + 1)
278
+
279
+ task = Models::Task.new(attrs.merge(id: child_id, parent_id: parent_id))
280
+ create_task(task)
281
+
282
+ dep = Models::Dependency.new(
283
+ source_id: parent_id,
284
+ target_id: child_id,
285
+ type: "parent-child"
286
+ )
287
+ add_dependency(dep)
288
+
289
+ task
290
+ end
291
+
292
+ # Plan operations
293
+
294
+ def find_plans
295
+ @db[:tasks].where(plan: true).order(:title).map do |row|
296
+ Models::Task.from_hash(row)
297
+ end
298
+ end
299
+
300
+ def find_plan_tasks(plan_id)
301
+ @db[:tasks].where(parent_id: plan_id).order(Sequel.asc(:priority), :title).map do |row|
302
+ Models::Task.from_hash(row)
303
+ end
304
+ end
305
+
306
+ def mark_as_plan(task_id)
307
+ task = find_task!(task_id)
308
+ task.plan = true
309
+ task.status = "open"
310
+ task.ephemeral = false
311
+ update_task(task)
312
+ end
313
+
314
+ # Workflow operations
315
+
316
+ def find_workflows(plan_id: nil)
317
+ dataset = @db[:tasks].where(plan: false)
318
+ dataset = dataset.exclude(source_plan_id: nil).exclude(source_plan_id: "")
319
+ dataset = dataset.where(source_plan_id: plan_id) if plan_id
320
+ dataset.order(Sequel.desc(:created_at)).map do |row|
321
+ Models::Task.from_hash(row)
322
+ end
323
+ end
324
+
325
+ def find_workflow_tasks(workflow_id)
326
+ @db[:tasks].where(parent_id: workflow_id).order(Sequel.asc(:priority), :title).map do |row|
327
+ Models::Task.from_hash(row)
328
+ end
329
+ end
330
+
331
+ # Ephemeral task operations
332
+
333
+ def find_ephemeral_workflows
334
+ @db[:tasks].where(ephemeral: true).map do |row|
335
+ Models::Task.from_hash(row)
336
+ end
337
+ end
338
+
339
+ def garbage_collect_ephemeral(max_age_hours: 24)
340
+ cutoff = Time.now.utc - (max_age_hours * 60 * 60)
341
+ old_ephemeral = @db[:tasks].where(ephemeral: true).where { created_at < cutoff }.select_map(:id)
342
+
343
+ old_ephemeral.each { |id| delete_task(id) }
344
+ old_ephemeral.size
345
+ end
346
+
347
+ # Bulk operations
348
+
349
+ def import_tasks(tasks)
350
+ @db.transaction do
351
+ tasks.each do |task|
352
+ existing = find_task(task.id)
353
+ if existing
354
+ update_task(task) if task.content_hash != existing.content_hash
355
+ else
356
+ @db[:tasks].insert(task_to_row(task))
357
+ end
358
+ end
359
+ end
360
+ rebuild_blocked_cache!
361
+ mark_dirty!
362
+ end
363
+
364
+ def clear!
365
+ @db[:tasks].delete
366
+ @db[:dependencies].delete
367
+ @db[:labels].delete
368
+ @db[:comments].delete
369
+ @db[:blocked_tasks].delete
370
+ mark_dirty!
371
+ end
372
+
373
+ private
374
+
375
+ def setup_schema
376
+ @db.create_table?(:tasks) do
377
+ String :id, primary_key: true
378
+ String :title, null: false
379
+ Text :description
380
+ String :status, default: "open"
381
+ Integer :priority, default: 2
382
+ String :type, default: "task"
383
+ String :assignee
384
+ String :parent_id
385
+ DateTime :created_at
386
+ DateTime :updated_at
387
+ DateTime :closed_at
388
+ String :content_hash
389
+ TrueClass :plan, default: false
390
+ String :source_plan_id
391
+ TrueClass :ephemeral, default: false
392
+ Text :notes
393
+
394
+ index :status
395
+ index :priority
396
+ index :type
397
+ index :assignee
398
+ index :parent_id
399
+ index :plan
400
+ index :source_plan_id
401
+ index :ephemeral
402
+ end
403
+
404
+ migrate_schema_if_needed!
405
+
406
+ @db.create_table?(:dependencies) do
407
+ String :id, primary_key: true
408
+ String :source_id, null: false
409
+ String :target_id, null: false
410
+ String :type, default: "blocks"
411
+ DateTime :created_at
412
+
413
+ index :source_id
414
+ index :target_id
415
+ index :type
416
+ end
417
+
418
+ @db.create_table?(:labels) do
419
+ String :id, primary_key: true
420
+ String :task_id, null: false
421
+ String :name, null: false
422
+ DateTime :created_at
423
+
424
+ index :task_id
425
+ index :name
426
+ unique %i[task_id name]
427
+ end
428
+
429
+ @db.create_table?(:comments) do
430
+ String :id, primary_key: true
431
+ String :task_id, null: false
432
+ String :author
433
+ Text :body, null: false
434
+ DateTime :created_at
435
+ DateTime :updated_at
436
+
437
+ index :task_id
438
+ end
439
+
440
+ @db.create_table?(:blocked_tasks) do
441
+ String :task_id, primary_key: true
442
+
443
+ index :task_id
444
+ end
445
+ end
446
+
447
+ def task_to_row(task)
448
+ {
449
+ id: task.id,
450
+ title: task.title,
451
+ description: task.description,
452
+ status: task.status,
453
+ priority: task.priority,
454
+ type: task.type,
455
+ assignee: task.assignee,
456
+ parent_id: task.parent_id,
457
+ created_at: task.created_at,
458
+ updated_at: task.updated_at,
459
+ closed_at: task.closed_at,
460
+ content_hash: task.content_hash,
461
+ plan: task.plan,
462
+ source_plan_id: task.source_plan_id,
463
+ ephemeral: task.ephemeral,
464
+ notes: task.notes
465
+ }
466
+ end
467
+
468
+ def dependency_to_row(dep)
469
+ {
470
+ id: dep.id,
471
+ source_id: dep.source_id,
472
+ target_id: dep.target_id,
473
+ type: dep.type,
474
+ created_at: dep.created_at
475
+ }
476
+ end
477
+
478
+ def label_to_row(label)
479
+ {
480
+ id: label.id,
481
+ task_id: label.task_id,
482
+ name: label.name,
483
+ created_at: label.created_at
484
+ }
485
+ end
486
+
487
+ def comment_to_row(comment)
488
+ {
489
+ id: comment.id,
490
+ task_id: comment.task_id,
491
+ author: comment.author,
492
+ body: comment.body,
493
+ created_at: comment.created_at,
494
+ updated_at: comment.updated_at
495
+ }
496
+ end
497
+
498
+ def apply_status_filter(dataset, status)
499
+ return dataset unless status
500
+
501
+ if status.is_a?(Array)
502
+ dataset.where(status: status)
503
+ else
504
+ dataset.where(status: status)
505
+ end
506
+ end
507
+
508
+ def apply_priority_filter(dataset, filters)
509
+ dataset = dataset.where(priority: filters[:priority]) if filters[:priority]
510
+ dataset = dataset.where { priority >= filters[:priority_min] } if filters[:priority_min]
511
+ dataset = dataset.where { priority <= filters[:priority_max] } if filters[:priority_max]
512
+ dataset
513
+ end
514
+
515
+ def apply_type_filter(dataset, type)
516
+ return dataset unless type
517
+
518
+ dataset.where(type: type)
519
+ end
520
+
521
+ def apply_assignee_filter(dataset, assignee)
522
+ return dataset unless assignee
523
+
524
+ dataset.where(assignee: assignee)
525
+ end
526
+
527
+ def apply_text_filters(dataset, filters)
528
+ if filters[:title_contains]
529
+ pattern = "%#{escape_like_pattern(filters[:title_contains])}%"
530
+ dataset = dataset.where(Sequel.ilike(:title, pattern))
531
+ end
532
+ if filters[:desc_contains]
533
+ pattern = "%#{escape_like_pattern(filters[:desc_contains])}%"
534
+ dataset = dataset.where(Sequel.ilike(:description, pattern))
535
+ end
536
+ if filters[:notes_contains]
537
+ pattern = "%#{escape_like_pattern(filters[:notes_contains])}%"
538
+ dataset = dataset.where(Sequel.ilike(:notes, pattern))
539
+ end
540
+ dataset
541
+ end
542
+
543
+ def escape_like_pattern(str)
544
+ str.gsub(/[%_\\]/) { |match| "\\#{match}" }
545
+ end
546
+
547
+ def apply_date_filters(dataset, filters)
548
+ dataset = dataset.where { created_at >= filters[:created_after] } if filters[:created_after]
549
+ dataset = dataset.where { created_at <= filters[:created_before] } if filters[:created_before]
550
+ dataset = dataset.where { updated_at >= filters[:updated_after] } if filters[:updated_after]
551
+ dataset = dataset.where { updated_at <= filters[:updated_before] } if filters[:updated_before]
552
+ dataset = dataset.where { closed_at >= filters[:closed_after] } if filters[:closed_after]
553
+ dataset = dataset.where { closed_at <= filters[:closed_before] } if filters[:closed_before]
554
+ dataset
555
+ end
556
+
557
+ def apply_null_filters(dataset, filters)
558
+ dataset = dataset.where(description: [nil, ""]) if filters[:empty_description]
559
+ dataset = dataset.where(assignee: nil) if filters[:no_assignee]
560
+ dataset
561
+ end
562
+
563
+ def detect_cycle!(new_dep)
564
+ return unless new_dep.blocking?
565
+
566
+ visited = Set.new
567
+ queue = [new_dep.target_id]
568
+
569
+ while queue.any?
570
+ current = queue.shift
571
+ return if visited.include?(current)
572
+
573
+ visited << current
574
+
575
+ raise DependencyCycleError, "Adding this dependency would create a cycle" if current == new_dep.source_id
576
+
577
+ @db[:dependencies]
578
+ .where(source_id: current)
579
+ .where(type: Models::Dependency::BLOCKING_TYPES)
580
+ .each { |row| queue << row[:target_id] }
581
+ end
582
+ end
583
+
584
+ def rebuild_blocked_cache!
585
+ @db[:blocked_tasks].delete
586
+
587
+ blocked = Set.new
588
+ open_tasks = @db[:tasks].where(status: "open").select_map(:id)
589
+
590
+ open_tasks.each do |task_id|
591
+ blocked << task_id if task_blocked?(task_id)
592
+ end
593
+
594
+ blocked.each do |id|
595
+ @db[:blocked_tasks].insert(task_id: id)
596
+ end
597
+ end
598
+
599
+ def task_blocked?(task_id, visited = Set.new)
600
+ return false if visited.include?(task_id)
601
+
602
+ visited << task_id
603
+ blocking_deps = @db[:dependencies]
604
+ .where(target_id: task_id)
605
+ .where(type: Models::Dependency::BLOCKING_TYPES)
606
+
607
+ blocking_deps.any? { |dep| blocker_active?(dep, visited) }
608
+ end
609
+
610
+ def blocker_active?(dep, visited)
611
+ blocker = @db[:tasks].where(id: dep[:source_id]).first
612
+ return false unless blocker
613
+
614
+ blocker_open = !%w[closed tombstone].include?(blocker[:status])
615
+ return true if blocker_open
616
+
617
+ dep[:type] == "parent-child" && task_blocked?(dep[:source_id], visited)
618
+ end
619
+
620
+ def migrate_schema_if_needed!
621
+ columns = @db[:tasks].columns
622
+
623
+ unless columns.include?(:plan)
624
+ @db.alter_table(:tasks) { add_column :plan, TrueClass, default: false } rescue nil
625
+ @db.add_index :tasks, :plan rescue nil
626
+ end
627
+ unless columns.include?(:source_plan_id)
628
+ @db.alter_table(:tasks) { add_column :source_plan_id, String } rescue nil
629
+ @db.add_index :tasks, :source_plan_id rescue nil
630
+ 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
635
+ end
636
+ end
637
+ end
638
+ end