alembic 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +105 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/builds/alembic/canvas.js +1 -0
  6. data/app/assets/builds/alembic/page_builder.css +1 -0
  7. data/app/assets/builds/alembic/page_builder.js +5 -0
  8. data/app/assets/stylesheets/alembic/application.css +15 -0
  9. data/app/controllers/alembic/application_controller.rb +32 -0
  10. data/app/controllers/alembic/flows_controller.rb +104 -0
  11. data/app/controllers/alembic/manage/base_controller.rb +16 -0
  12. data/app/controllers/alembic/manage/canvas_controller.rb +183 -0
  13. data/app/controllers/alembic/manage/definitions_controller.rb +15 -0
  14. data/app/controllers/alembic/manage/flows_controller.rb +55 -0
  15. data/app/controllers/alembic/manage/page_blocks_controller.rb +19 -0
  16. data/app/controllers/alembic/manage/pages_controller.rb +38 -0
  17. data/app/controllers/alembic/manage/previews_controller.rb +38 -0
  18. data/app/controllers/alembic/manage/versions_controller.rb +22 -0
  19. data/app/controllers/concerns/alembic/manage/authenticates_admin.rb +19 -0
  20. data/app/controllers/concerns/alembic/manage/draws_canvas.rb +35 -0
  21. data/app/helpers/alembic/application_helper.rb +11 -0
  22. data/app/javascript/alembic/canvas/Canvas.jsx +197 -0
  23. data/app/javascript/alembic/canvas/Connector.jsx +30 -0
  24. data/app/javascript/alembic/canvas/ConnectorLayer.jsx +31 -0
  25. data/app/javascript/alembic/canvas/Control.jsx +42 -0
  26. data/app/javascript/alembic/canvas/Inspector.jsx +44 -0
  27. data/app/javascript/alembic/canvas/Panel.jsx +67 -0
  28. data/app/javascript/alembic/canvas/Placeholder.jsx +24 -0
  29. data/app/javascript/alembic/canvas/Port.jsx +16 -0
  30. data/app/javascript/alembic/canvas/Records.jsx +34 -0
  31. data/app/javascript/alembic/canvas/StepCard.jsx +57 -0
  32. data/app/javascript/alembic/canvas/Toolbar.jsx +19 -0
  33. data/app/javascript/alembic/canvas/TypePicker.jsx +21 -0
  34. data/app/javascript/alembic/canvas/changes.js +5 -0
  35. data/app/javascript/alembic/canvas/choices.js +4 -0
  36. data/app/javascript/alembic/canvas/flow.js +7 -0
  37. data/app/javascript/alembic/canvas/flowSender.js +22 -0
  38. data/app/javascript/alembic/canvas/ids.js +7 -0
  39. data/app/javascript/alembic/canvas/rows.js +2 -0
  40. data/app/javascript/alembic/canvas/styles.js +3 -0
  41. data/app/javascript/alembic/canvas/useConnectors.js +75 -0
  42. data/app/javascript/alembic/canvas/useFlow.js +17 -0
  43. data/app/javascript/alembic/canvas.jsx +13 -0
  44. data/app/javascript/alembic/page_builder/PageBuilder.jsx +18 -0
  45. data/app/javascript/alembic/page_builder.jsx +10 -0
  46. data/app/jobs/alembic/application_job.rb +4 -0
  47. data/app/mailers/alembic/application_mailer.rb +6 -0
  48. data/app/models/alembic/application_record.rb +5 -0
  49. data/app/models/alembic/asked.rb +3 -0
  50. data/app/models/alembic/choice.rb +7 -0
  51. data/app/models/alembic/flow/admission.rb +19 -0
  52. data/app/models/alembic/flow/canvas.rb +167 -0
  53. data/app/models/alembic/flow/change.rb +18 -0
  54. data/app/models/alembic/flow/condition.rb +24 -0
  55. data/app/models/alembic/flow/definition.rb +132 -0
  56. data/app/models/alembic/flow/digest.rb +116 -0
  57. data/app/models/alembic/flow/document.rb +131 -0
  58. data/app/models/alembic/flow/drawing.rb +15 -0
  59. data/app/models/alembic/flow/edge.rb +9 -0
  60. data/app/models/alembic/flow/edit_history.rb +75 -0
  61. data/app/models/alembic/flow/layout.rb +88 -0
  62. data/app/models/alembic/flow/name.rb +15 -0
  63. data/app/models/alembic/flow/node.rb +5 -0
  64. data/app/models/alembic/flow/output.rb +23 -0
  65. data/app/models/alembic/flow/progress/kept.rb +38 -0
  66. data/app/models/alembic/flow/progress/loose.rb +43 -0
  67. data/app/models/alembic/flow/progress.rb +11 -0
  68. data/app/models/alembic/flow/registry.rb +25 -0
  69. data/app/models/alembic/flow/run.rb +54 -0
  70. data/app/models/alembic/flow/runner.rb +65 -0
  71. data/app/models/alembic/flow/settings.rb +80 -0
  72. data/app/models/alembic/flow/start.rb +13 -0
  73. data/app/models/alembic/flow/step.rb +42 -0
  74. data/app/models/alembic/flow/step_type/declaration.rb +118 -0
  75. data/app/models/alembic/flow/step_type.rb +69 -0
  76. data/app/models/alembic/flow/summaries.rb +44 -0
  77. data/app/models/alembic/flow/summary_version.rb +13 -0
  78. data/app/models/alembic/flow/switch.rb +17 -0
  79. data/app/models/alembic/flow/terminal.rb +13 -0
  80. data/app/models/alembic/flow/validator.rb +171 -0
  81. data/app/models/alembic/flow/version.rb +26 -0
  82. data/app/models/alembic/flow/violation.rb +9 -0
  83. data/app/models/alembic/flow.rb +44 -0
  84. data/app/models/alembic/outputs/band.rb +22 -0
  85. data/app/models/alembic/outputs/grouped.rb +26 -0
  86. data/app/models/alembic/outputs/lowest.rb +20 -0
  87. data/app/models/alembic/outputs/percentage.rb +31 -0
  88. data/app/models/alembic/outputs/tally.rb +23 -0
  89. data/app/models/alembic/outputs/weighted_sum.rb +26 -0
  90. data/app/models/alembic/page.rb +11 -0
  91. data/app/models/alembic/runner.rb +13 -0
  92. data/app/models/alembic/steps/question.rb +52 -0
  93. data/app/models/alembic/summary/output_type.rb +40 -0
  94. data/app/models/alembic/summary/registry.rb +25 -0
  95. data/app/models/alembic/summary/report.rb +31 -0
  96. data/app/models/alembic/summary/result.rb +5 -0
  97. data/app/models/alembic/summary/run.rb +13 -0
  98. data/app/models/alembic/summary.rb +15 -0
  99. data/app/views/alembic/flow/steps/_choosing.html.erb +9 -0
  100. data/app/views/alembic/flows/complete.html.erb +34 -0
  101. data/app/views/alembic/flows/show.html.erb +17 -0
  102. data/app/views/alembic/flows/step.html.erb +27 -0
  103. data/app/views/alembic/manage/definitions/edit.html.erb +13 -0
  104. data/app/views/alembic/manage/flows/edit.html.erb +29 -0
  105. data/app/views/alembic/manage/flows/index.html.erb +32 -0
  106. data/app/views/alembic/manage/flows/show.html.erb +26 -0
  107. data/app/views/alembic/manage/pages/_block_content.html.erb +1 -0
  108. data/app/views/alembic/manage/pages/index.html.erb +20 -0
  109. data/app/views/alembic/manage/pages/show.html.erb +11 -0
  110. data/app/views/alembic/manage/versions/index.html.erb +41 -0
  111. data/app/views/layouts/alembic/application.html.erb +17 -0
  112. data/config/routes.rb +42 -0
  113. data/db/migrate/20260604215031_create_alembic_diagnostics.rb +14 -0
  114. data/db/migrate/20260605015051_create_alembic_questions.rb +12 -0
  115. data/db/migrate/20260605021526_create_alembic_options.rb +14 -0
  116. data/db/migrate/20260605023308_create_alembic_conditions.rb +11 -0
  117. data/db/migrate/20260605025623_create_alembic_bands.rb +12 -0
  118. data/db/migrate/20260605032859_create_alembic_condition_options.rb +10 -0
  119. data/db/migrate/20260605032900_reshape_conditions_for_placement.rb +11 -0
  120. data/db/migrate/20260605125119_create_alembic_results.rb +14 -0
  121. data/db/migrate/20260605130338_create_alembic_rules.rb +10 -0
  122. data/db/migrate/20260605130339_create_alembic_rule_results.rb +10 -0
  123. data/db/migrate/20260605210714_add_definition_to_alembic_diagnostics.rb +5 -0
  124. data/db/migrate/20260606000855_add_guide_copy_to_alembic_diagnostics.rb +9 -0
  125. data/db/migrate/20260606005430_create_alembic_nodes.rb +22 -0
  126. data/db/migrate/20260606010312_create_alembic_build_steps.rb +12 -0
  127. data/db/migrate/20260606011110_create_alembic_warnings.rb +11 -0
  128. data/db/migrate/20260804180000_create_alembic_domains.rb +14 -0
  129. data/db/migrate/20260804180100_add_domain_to_alembic_questions.rb +5 -0
  130. data/db/migrate/20260822120000_create_alembic_definition_versions.rb +14 -0
  131. data/db/migrate/20260822120100_backfill_alembic_definition_versions.rb +17 -0
  132. data/db/migrate/20260822130000_remove_definition_from_alembic_diagnostics.rb +28 -0
  133. data/db/migrate/20260822140000_create_alembic_responses.rb +14 -0
  134. data/db/migrate/20260824120000_create_alembic_transitions.rb +10 -0
  135. data/db/migrate/20260824130000_drop_alembic_builder_tables.rb +17 -0
  136. data/db/migrate/20260824140000_add_definition_cursor_to_alembic_diagnostics.rb +5 -0
  137. data/db/migrate/20260824160000_add_summary_definition_to_alembic_diagnostics.rb +5 -0
  138. data/db/migrate/20260824170000_drop_alembic_guide_copy_columns.rb +8 -0
  139. data/db/migrate/20260825120000_create_alembic_summary_versions.rb +14 -0
  140. data/db/migrate/20260825130000_add_summary_cursor_to_alembic_diagnostics.rb +5 -0
  141. data/db/migrate/20260825140000_move_summary_definitions_into_versions.rb +26 -0
  142. data/db/migrate/20260825150000_drop_summary_definition_from_alembic_diagnostics.rb +5 -0
  143. data/db/migrate/20260825160000_add_summary_version_to_alembic_responses.rb +6 -0
  144. data/db/migrate/20260825170000_rename_question_settings_in_current_definitions.rb +51 -0
  145. data/db/migrate/20260825180000_add_document_to_alembic_diagnostics.rb +6 -0
  146. data/db/migrate/20260825190000_seed_documents_from_current_definitions.rb +29 -0
  147. data/db/migrate/20260825200000_add_published_version_to_alembic_diagnostics.rb +6 -0
  148. data/db/migrate/20260825210000_publish_current_definitions.rb +29 -0
  149. data/db/migrate/20260825220000_add_undone_changes_to_alembic_diagnostics.rb +5 -0
  150. data/db/migrate/20260825223322_add_status_to_alembic_definition_versions.rb +8 -0
  151. data/db/migrate/20260825224538_remove_published_version_from_alembic_diagnostics.rb +5 -0
  152. data/db/migrate/20260825225015_change_alembic_diagnostic_status_to_availability.rb +16 -0
  153. data/db/migrate/20260825230000_add_undo_history_to_alembic_diagnostics.rb +5 -0
  154. data/db/migrate/20260825230500_add_changes_to_alembic_definition_versions.rb +5 -0
  155. data/db/migrate/20260826230000_rename_alembic_responses_to_flow_runs.rb +5 -0
  156. data/db/migrate/20260826234500_rename_answers_to_recorded_on_flow_runs.rb +5 -0
  157. data/db/migrate/20260826235000_rename_definition_versions_to_flow_versions.rb +5 -0
  158. data/db/migrate/20260826235500_rename_summary_versions_to_flow_summaries.rb +5 -0
  159. data/db/migrate/20260827000000_rename_diagnostics_to_flows.rb +8 -0
  160. data/db/migrate/20260831000000_add_persists_to_alembic_flows.rb +5 -0
  161. data/db/migrate/20260914120000_create_alembic_pages.rb +9 -0
  162. data/db/migrate/20260915120000_add_blocks_to_alembic_pages.rb +5 -0
  163. data/lib/alembic/engine.rb +43 -0
  164. data/lib/alembic/leftover_stylesheet.rb +14 -0
  165. data/lib/alembic/version.rb +3 -0
  166. data/lib/alembic.rb +34 -0
  167. data/lib/tasks/alembic_tasks.rake +4 -0
  168. metadata +252 -0
@@ -0,0 +1,167 @@
1
+ module Alembic
2
+ module Flow
3
+ class Canvas
4
+ def initialize(document, registry: Flow.registry)
5
+ @document = document
6
+ @registry = registry
7
+ end
8
+
9
+ def to_h
10
+ { "nodes" => nodes, "edges" => edges, "palette" => palette, "violations" => violations }
11
+ end
12
+
13
+ private
14
+
15
+ def nodes
16
+ @document.nodes.map { |node| drawn_step(node) } + waiting.map { |gap| drawn_placeholder(gap) }
17
+ end
18
+
19
+ def drawn_step(node)
20
+ { "id" => node.id, "type" => node.type, "label" => label_for(node),
21
+ "config" => node.config, "ports" => [], "ends_here" => ends_here?(node), "begins_here" => begins_here?(node),
22
+ "loose" => loose?(node), "choices" => choices_for(node), "placeholder" => false, **placed[node.id] }
23
+ end
24
+
25
+ def drawn_placeholder(gap)
26
+ { "id" => gap[:id], "type" => "placeholder", "label" => gap[:on], "config" => {}, "ports" => [],
27
+ "ends_here" => false, "begins_here" => false, "loose" => false, "choices" => {},
28
+ "placeholder" => true, "from" => gap[:from], "on" => gap[:on], **placed[gap[:id]] }
29
+ end
30
+
31
+ def edges
32
+ drawn.edges.each_with_index.map do |edge, index|
33
+ { "id" => "#{edge.from}-#{edge.to}-#{index}", "source" => edge.from, "target" => edge.to, "label" => edge.on&.to_s,
34
+ "placeholder" => waiting.any? { |gap| gap[:id] == edge.to } }.merge(routing(edge, placed))
35
+ end
36
+ end
37
+
38
+ def placed
39
+ @placed ||= Layout.new(drawn).positions
40
+ end
41
+
42
+ def drawn
43
+ @drawn ||= Document.new(@document.to_h.merge(
44
+ "nodes" => Array(@document.to_h["nodes"]) + waiting.map { |gap| { "id" => gap[:id], "type" => "placeholder" } },
45
+ "edges" => Array(@document.to_h["edges"]) + waiting.map { |gap| { "from" => gap[:from], "to" => gap[:id], "on" => gap[:on] } }
46
+ ), registry: @registry)
47
+ end
48
+
49
+ def waiting
50
+ @waiting ||= @document.nodes.flat_map { |node| gaps_after(node) }
51
+ end
52
+
53
+ def gaps_after(node)
54
+ wired = @document.edges_from(node.id).map { |edge| edge.on.to_s }
55
+ results = digest.routing_values(node.id)
56
+ return [ { id: "#{node.id}--", from: node.id, on: nil } ] if results.empty? && leads_nowhere?(node)
57
+
58
+ results.reject { |value| wired.include?(value) }
59
+ .map { |value| { id: "#{node.id}--#{value}", from: node.id, on: value } }
60
+ end
61
+
62
+ def leads_nowhere?(node)
63
+ !ends_here?(node) && !loose?(node) && @document.edges_from(node.id).none?
64
+ end
65
+
66
+ def routing(edge, placed)
67
+ from = placed[edge.from]
68
+ to = placed[edge.to]
69
+ return { "leaves" => "bottom", "enters" => "top", "route" => "straight" } unless from && to
70
+
71
+ return alongside_routing(from, to) unless to["row"] > from["row"]
72
+
73
+ { "leaves" => "bottom", "enters" => "top",
74
+ "route" => to["column"] == from["column"] ? "straight" : "lane" }
75
+ end
76
+
77
+ def alongside_routing(from, to)
78
+ leftward = to["column"] < from["column"]
79
+
80
+ { "leaves" => leftward ? "left" : "right", "enters" => leftward ? "right" : "left",
81
+ "route" => to["row"] == from["row"] ? "straight" : "detour" }
82
+ end
83
+
84
+ def palette
85
+ @registry.step_types.reject(&:begins_here?).map do |step_type|
86
+ { "type" => step_type.id.to_s, "label" => step_type.step_name,
87
+ "fields" => step_type.settings.fields.transform_keys(&:to_s).transform_values(&:to_s),
88
+ "labels" => step_type.settings.labels.transform_keys(&:to_s),
89
+ "choices" => step_type.settings.choices.transform_keys(&:to_s),
90
+ "records" => holdings_of(step_type),
91
+ "record_labels" => step_type.settings.record_labels.to_h { |name, held| [ name.to_s, held.transform_keys(&:to_s) ] },
92
+ "awaits_input" => step_type.awaits_input? }
93
+ end
94
+ end
95
+
96
+ def holdings_of(step_type)
97
+ step_type.settings.record_fields.to_h do |name, holds|
98
+ [ name.to_s, holds.transform_keys(&:to_s).transform_values(&:to_s) ]
99
+ end
100
+ end
101
+
102
+ def violations
103
+ Validator.new(@document, registry: @registry).violations.map do |violation|
104
+ { "node" => violation.node, "problem" => violation.problem.to_s, "detail" => violation.detail }
105
+ end
106
+ end
107
+
108
+ def label_for(node)
109
+ Name.of(node, @registry)
110
+ end
111
+
112
+ def choices_for(node)
113
+ naming_steps(node).index_with { earlier_than(node) }.merge(drawn_by(node)).merge(named_outputs_by(node))
114
+ end
115
+
116
+ def named_outputs_by(node)
117
+ step_type_for(node)&.settings&.outputs_of.to_h.to_h do |name, source|
118
+ [ name.to_s, digest.outputs_of(node.config[source.to_s]) ]
119
+ end
120
+ end
121
+
122
+ def drawn_by(node)
123
+ step_type_for(node)&.settings&.drawn_from.to_h.to_h do |name, source|
124
+ [ name.to_s, digest.values_of(step_named_by(node, source), node.config[source.to_s]) ]
125
+ end
126
+ end
127
+
128
+ def step_named_by(node, source)
129
+ node.config[step_type_for(node).settings.outputs_of[source].to_s]
130
+ end
131
+
132
+ def digest
133
+ Digest.new(@document, registry: @registry)
134
+ end
135
+
136
+ def naming_steps(node)
137
+ step_type_for(node)&.settings&.naming_steps.to_a.map(&:to_s)
138
+ end
139
+
140
+ def earlier_than(node)
141
+ digest.preceding(node.id).map do |id|
142
+ { "value" => id, "label" => label_for(@document.node(id)) }
143
+ end
144
+ end
145
+
146
+ def ends_here?(node)
147
+ step_type_for(node)&.ends_here? || false
148
+ end
149
+
150
+ def loose?(node)
151
+ @document.entry.present? && @document.reachable.exclude?(node.id)
152
+ end
153
+
154
+ def begins_here?(node)
155
+ step_type_for(node)&.begins_here? || false
156
+ end
157
+
158
+ def ports_for(node)
159
+ digest.routing_values(node.id)
160
+ end
161
+
162
+ def step_type_for(node)
163
+ @registry.fetch(node.type) if @registry.registered?(node.type)
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,18 @@
1
+ module Alembic
2
+ module Flow
3
+ module Change
4
+ PHRASING = { "added" => "Added", "updated" => "Updated", "removed" => "Removed",
5
+ "moved" => "Moved", "connected" => "Connected", "disconnected" => "Disconnected", "returned" => "Returned to" }.freeze
6
+
7
+ def self.phrase(change)
8
+ named = Array(change["named"]).compact_blank.map { |name| "“#{name}”" }
9
+ verb = PHRASING.fetch(change["action"], change["action"])
10
+
11
+ return "#{verb} #{named.join(' → ')}" if named.any?
12
+ return "#{verb} #{change['detail']}" if change["detail"].present?
13
+
14
+ verb
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,24 @@
1
+ module Alembic
2
+ module Flow
3
+ class Condition
4
+ include Step
5
+
6
+ step_name "Condition"
7
+
8
+ setting :step, type: :previous_step
9
+ setting :output, outputs_of: :step, required: true
10
+ setting :comparison, type: :select, options: [ "is", "is not" ], required: true
11
+ setting :answer, from: :output, required: true
12
+
13
+ output :result, type: :boolean, values: [ true, false ]
14
+
15
+ names_by { |node| node.config.values_at("step", "comparison", "answer").compact_blank.join(" ").presence }
16
+
17
+ def route(node, state)
18
+ matched = state[node.config["step"]] == node.config["answer"]
19
+
20
+ node.config["comparison"] == "is not" ? !matched : matched
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,132 @@
1
+ module Alembic
2
+ module Flow
3
+ class Definition < ApplicationRecord
4
+ self.table_name = "alembic_flows"
5
+
6
+ enum :status, { active: "active", hidden: "hidden", inactive: "inactive" }
7
+ enum :kind, { scored: "scored", guide: "guide" }
8
+ enum :persists, { unsaved: "unsaved", each_step: "each_step", on_finish: "on_finish" }
9
+
10
+ validates :slug, presence: true
11
+
12
+ after_initialize :begin_the_flow, if: :new_record?
13
+
14
+ scope :listable, -> { active }
15
+
16
+ # Declared before :definition_versions so responses clear first, otherwise
17
+ # destroying a diagnostic trips the responses -> definition_versions FK.
18
+ has_many :runs, class_name: "Alembic::Flow::Run", foreign_key: :flow_id, dependent: :destroy
19
+ has_many :definition_versions, class_name: "Alembic::Flow::Version", foreign_key: :flow_id, dependent: :destroy
20
+ has_many :summary_versions, class_name: "Alembic::Flow::SummaryVersion", foreign_key: :flow_id, dependent: :destroy
21
+
22
+ def self.upsert_definition(definition)
23
+ find_or_initialize_by(slug: definition["slug"]).tap do |diagnostic|
24
+ diagnostic.save!
25
+ diagnostic.record_definition(definition) unless diagnostic.definition == definition
26
+ end
27
+ end
28
+
29
+ def definition
30
+ current_definition_version&.definition
31
+ end
32
+
33
+ def current_definition_version
34
+ definition_versions.find_by(number: cursor)
35
+ end
36
+
37
+ def edit_history
38
+ @edit_history ||= EditHistory.new(self)
39
+ end
40
+
41
+ def summaries
42
+ @summaries ||= Summaries.new(self)
43
+ end
44
+
45
+ def publish
46
+ create_version
47
+
48
+ publish_version(current_definition_version)
49
+ end
50
+
51
+ def return_to(version)
52
+ raise ActiveRecord::RecordNotFound unless definition_versions.exists?(version.id)
53
+ raise OutOfService if version.out_of_service?
54
+
55
+ update!(document: version.definition, undone_changes: [],
56
+ changes_since_version: changes_since_version.to_a + [ returning_to(version) ])
57
+ end
58
+
59
+ def create_version
60
+ record_definition(document, changes_since_version.to_a) unless versioned?
61
+
62
+ update!(undo_history: edit_history.undoable, changes_since_version: [], undone_changes: [])
63
+ end
64
+
65
+ def returning_to(version)
66
+ { "action" => "returned", "steps" => [], "named" => [],
67
+ "detail" => "version #{version.number}", "before" => document }
68
+ end
69
+
70
+ def versioned?
71
+ document == definition
72
+ end
73
+
74
+ def record_definition(payload, captured = [])
75
+ definition_versions.create!(number: next_definition_number, definition: payload,
76
+ changes_captured: captured.map { |change| change.except("before") })
77
+ .tap { |version| update!(definition_cursor: version.number, document: payload) }
78
+ end
79
+
80
+ def publish_version(version)
81
+ raise OutOfService if version.out_of_service?
82
+ return if live_version == version
83
+
84
+ live_version&.update!(status: :superseded)
85
+ version.update!(status: :live)
86
+ end
87
+
88
+ def available?
89
+ !inactive?
90
+ end
91
+
92
+ def retire_version(version)
93
+ version.update!(status: :retired)
94
+ end
95
+
96
+ def withdraw_version(version)
97
+ version.update!(status: :withdrawn)
98
+ end
99
+
100
+ def live_version
101
+ definition_versions.find_by(status: :live)
102
+ end
103
+
104
+ def live_definition
105
+ live_version&.definition
106
+ end
107
+
108
+ def runner
109
+ Runner.new(live_definition)
110
+ end
111
+
112
+ private
113
+
114
+ def begin_the_flow
115
+ self.document ||= { "nodes" => [ { "id" => "start", "type" => "start" }, { "id" => "end", "type" => "terminal" } ],
116
+ "edges" => [ { "from" => "start", "to" => "end" } ] }
117
+ end
118
+
119
+ def cursor
120
+ definition_cursor || recorded_numbers.max
121
+ end
122
+
123
+ def recorded_numbers
124
+ definition_versions.pluck(:number)
125
+ end
126
+
127
+ def next_definition_number
128
+ (definition_versions.maximum(:number) || 0) + 1
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,116 @@
1
+ module Alembic
2
+ module Flow
3
+ class Digest
4
+ def initialize(document, registry: Flow.registry)
5
+ @document = document
6
+ @registry = registry
7
+ end
8
+
9
+ def entry
10
+ @document.node(@document.entry)
11
+ end
12
+
13
+ def step(id)
14
+ @document.node(id)
15
+ end
16
+
17
+ def steps
18
+ @document.nodes
19
+ end
20
+
21
+ def preceding(id)
22
+ return [] unless @document.reachable.include?(id)
23
+
24
+ @document.nodes.map(&:id).uniq
25
+ .reject { |other| other == id || @document.reachable(without: other).include?(id) }
26
+ end
27
+
28
+ def routing_values(id)
29
+ named = step(id)
30
+ return [] unless named && step_type(named)&.routes?
31
+
32
+ step_type(named).outputs.flat_map { |output| values_taken(output, named) }.map { |value| value["value"].to_s }
33
+ end
34
+
35
+ def values_taken(output, node)
36
+ return output.values_for(node) unless output.from
37
+
38
+ values_out_of(node.config[output.from.to_s])
39
+ end
40
+
41
+ def values_of(id, output_name)
42
+ named = step(id)
43
+ return [] unless named && output_name.present?
44
+
45
+ step_type(named)&.values_of(output_name, named).to_a
46
+ end
47
+
48
+ def outputs_of(id)
49
+ named = step(id)
50
+ return [] unless named
51
+
52
+ step_type(named)&.outputs.to_a.map { |output| { "value" => output.name.to_s, "label" => output.label } }
53
+ end
54
+
55
+ def values_out_of(id)
56
+ named = step(id)
57
+ return [] unless named
58
+
59
+ step_type(named)&.outputs.to_a.flat_map { |output| output.values_for(named) }
60
+ end
61
+
62
+ def requirements(id)
63
+ node = step(id)
64
+ step_type(node)&.settings&.requirements_for(node.config).to_a
65
+ end
66
+
67
+ def next_step(state)
68
+ walk(state).last
69
+ end
70
+
71
+ def state_on_path(state)
72
+ state.slice(*walk(state).first)
73
+ end
74
+
75
+ private
76
+
77
+ def walk(state)
78
+ recorded = []
79
+ visited = []
80
+ cursor = entry
81
+
82
+ while cursor && !visited.include?(cursor.id)
83
+ return [ recorded, cursor ] if pending?(cursor, state)
84
+
85
+ recorded << cursor.id if state.key?(cursor.id)
86
+ visited << cursor.id
87
+ cursor = successor(cursor, state)
88
+ end
89
+
90
+ [ recorded, nil ]
91
+ end
92
+
93
+ def pending?(node, state)
94
+ return false if state.key?(node.id)
95
+
96
+ step_type(node)&.awaits_input? || acts?(node)
97
+ end
98
+
99
+ def acts?(node)
100
+ step_type(node)&.acts? || false
101
+ end
102
+
103
+ def successor(node, state)
104
+ leaving = @document.edges_from(node.id)
105
+ port = step_type(node)&.route(node, state)
106
+ taken = port.nil? ? leaving.first : leaving.find { |edge| edge.on.to_s == port.to_s }
107
+
108
+ taken && @document.node(taken.to)
109
+ end
110
+
111
+ def step_type(node)
112
+ @registry.fetch(node.type) if node && @registry.registered?(node.type)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,131 @@
1
+ module Alembic
2
+ module Flow
3
+ class Document
4
+ def initialize(document, registry: Flow.registry)
5
+ @document = document
6
+ @registry = registry
7
+ end
8
+
9
+ def nodes
10
+ Array(@document["nodes"]).map { |node| Node.new(id: node["id"], type: node["type"], config: node.except("id", "type")) }
11
+ end
12
+
13
+ def edges
14
+ Array(@document["edges"]).map { |edge| Edge.new(from: edge["from"], to: edge["to"], on: edge["on"]) }
15
+ end
16
+
17
+ def entry
18
+ beginnings.first
19
+ end
20
+
21
+ def beginnings
22
+ nodes.select { |node| begins_here?(node) }.map(&:id)
23
+ end
24
+
25
+ def begins_here?(node)
26
+ @registry.registered?(node.type) && @registry.fetch(node.type).begins_here?
27
+ end
28
+
29
+ def node(id)
30
+ nodes.find { |node| node.id == id }
31
+ end
32
+
33
+ def edges_from(id)
34
+ edges.select { |edge| edge.from == id }
35
+ end
36
+
37
+ def reachable(without: nil)
38
+ found = []
39
+ frontier = [ entry ]
40
+
41
+ while (id = frontier.shift)
42
+ next if found.include?(id) || id == without
43
+
44
+ found << id
45
+ frontier.concat(edges_from(id).map(&:to))
46
+ end
47
+
48
+ found
49
+ end
50
+
51
+ def to_h
52
+ @document
53
+ end
54
+
55
+ def insert(node, on:, leaving: nil)
56
+ source, target = on
57
+ replaced = raw_edges.find { |edge| edge["from"] == source && edge["to"] == target }
58
+ bridged = [ { "from" => source, "to" => node["id"], "on" => replaced&.fetch("on", nil) }.compact,
59
+ { "from" => node["id"], "to" => target, "on" => leaving }.compact ]
60
+
61
+ with(nodes: raw_nodes + [ node ], edges: (raw_edges - [ replaced ]) + bridged)
62
+ end
63
+
64
+ def add(node)
65
+ with(nodes: raw_nodes + [ node ], edges: raw_edges)
66
+ end
67
+
68
+ def connect(from:, to:, on: nil)
69
+ with(nodes: raw_nodes, edges: raw_edges + [ { "from" => from, "to" => to, "on" => on }.compact ])
70
+ end
71
+
72
+ def disconnect(from:, to:)
73
+ with(nodes: raw_nodes, edges: raw_edges.reject { |edge| edge["from"] == from && edge["to"] == to })
74
+ end
75
+
76
+ def move(id, on:, leaving: nil)
77
+ step = raw_nodes.find { |node| node["id"] == id }
78
+ return self unless step
79
+
80
+ remove(id).insert(step, on: on, leaving: leaving)
81
+ end
82
+
83
+ def configure(id, config)
84
+ reconfigured = raw_nodes.map do |node|
85
+ node["id"] == id ? node.slice("id", "type").merge(config) : node
86
+ end
87
+
88
+ with(nodes: reconfigured, edges: raw_edges)
89
+ end
90
+
91
+ def rewire(from:, to:, target:)
92
+ rewritten = raw_edges.map do |edge|
93
+ edge["from"] == from && edge["to"] == to ? edge.merge("to" => target) : edge
94
+ end
95
+
96
+ with(nodes: raw_nodes, edges: rewritten)
97
+ end
98
+
99
+ def remove(id)
100
+ incoming = raw_edges.select { |edge| edge["to"] == id }
101
+ outgoing = raw_edges.select { |edge| edge["from"] == id }
102
+
103
+ with(nodes: raw_nodes.reject { |node| node["id"] == id },
104
+ edges: (raw_edges - incoming - outgoing) + bridges(incoming, outgoing))
105
+ end
106
+
107
+ private
108
+
109
+ def bridges(incoming, outgoing)
110
+ incoming.product(outgoing).map do |arriving, leaving|
111
+ { "from" => arriving["from"], "to" => leaving["to"], "on" => arriving["on"] }.compact
112
+ end
113
+ end
114
+
115
+ def raw_nodes
116
+ Array(@document["nodes"])
117
+ end
118
+
119
+ def raw_edges
120
+ Array(@document["edges"])
121
+ end
122
+
123
+ def with(nodes:, edges:)
124
+ Document.new(@document.merge("nodes" => nodes, "edges" => edges), registry: @registry).tap do |edited|
125
+ broken = Validator.new(edited, registry: @registry).malformations
126
+ raise InvalidEdit, broken.map { |violation| "#{violation.node}: #{violation.problem}" }.join(", ") if broken.any?
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,15 @@
1
+ module Alembic
2
+ module Flow
3
+ module Drawing
4
+ def self.of(node, registry = Flow.registry)
5
+ named(node, registry).presence || Flow.drawing
6
+ end
7
+
8
+ def self.named(node, registry)
9
+ return unless node && registry.registered?(node.type)
10
+
11
+ registry.fetch(node.type).drawn_by
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ module Alembic
2
+ module Flow
3
+ Edge = Data.define(:from, :to, :on) do
4
+ def initialize(from:, to:, on: nil)
5
+ super
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,75 @@
1
+ module Alembic
2
+ module Flow
3
+ class EditHistory
4
+ def initialize(flow)
5
+ @flow = flow
6
+ end
7
+
8
+ def undoable?
9
+ undoable.any?
10
+ end
11
+
12
+ def redoable?
13
+ undone.any?
14
+ end
15
+
16
+ def undoable
17
+ @flow.undo_history.to_a + @flow.changes_since_version.to_a
18
+ end
19
+
20
+ def undo_change
21
+ undone_change = undoable.last
22
+ return unless undone_change
23
+
24
+ @flow.update!(document: undone_change["before"],
25
+ undone_changes: undone + [ undone_change.merge("after" => @flow.document) ],
26
+ undo_history: @flow.undo_history.to_a[0...-1],
27
+ changes_since_version: @flow.changes_since_version.to_a[0...-1])
28
+ end
29
+
30
+ def redo_change
31
+ redone = undone.last
32
+ return unless redone
33
+
34
+ @flow.update!(document: redone["after"], undone_changes: undone[0...-1],
35
+ changes_since_version: @flow.changes_since_version.to_a + [ redone.except("after") ])
36
+ end
37
+
38
+ def record(edited, action, steps, before)
39
+ @flow.update!(document: edited.to_h, undone_changes: [],
40
+ changes_since_version: with(edited, action, steps, before))
41
+ end
42
+
43
+ def edit_document(payload)
44
+ @flow.update!(document: payload, undone_changes: [],
45
+ changes_since_version: @flow.changes_since_version.to_a + [ edited_by_hand ])
46
+ end
47
+
48
+ private
49
+
50
+ def with(edited, action, steps, before)
51
+ return @flow.changes_since_version.to_a unless action
52
+
53
+ @flow.changes_since_version.to_a +
54
+ [ { "action" => action.to_s, "steps" => steps.map(&:to_s),
55
+ "named" => steps.map { |id| named(edited, id) }, "before" => before } ]
56
+ end
57
+
58
+ def named(edited, id)
59
+ Name.of(edited.node(id.to_s) || document.node(id.to_s))
60
+ end
61
+
62
+ def document
63
+ Document.new(@flow.document || @flow.definition || {})
64
+ end
65
+
66
+ def undone
67
+ @flow.undone_changes.to_a
68
+ end
69
+
70
+ def edited_by_hand
71
+ { "action" => "edited", "steps" => [], "named" => [ "the definition" ], "before" => @flow.document }
72
+ end
73
+ end
74
+ end
75
+ end