dynflow 1.8.4 → 1.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/examples/halt.rb +71 -0
- data/lib/dynflow/coordinator.rb +13 -0
- data/lib/dynflow/director/execution_plan_manager.rb +8 -1
- data/lib/dynflow/director/running_steps_manager.rb +9 -0
- data/lib/dynflow/director.rb +28 -0
- data/lib/dynflow/dispatcher/client_dispatcher.rb +5 -1
- data/lib/dynflow/dispatcher/executor_dispatcher.rb +7 -1
- data/lib/dynflow/dispatcher.rb +5 -1
- data/lib/dynflow/execution_plan.rb +10 -0
- data/lib/dynflow/executors/abstract/core.rb +4 -0
- data/lib/dynflow/executors/parallel.rb +4 -0
- data/lib/dynflow/version.rb +1 -1
- data/lib/dynflow/world/invalidation.rb +15 -0
- data/lib/dynflow/world.rb +7 -0
- data/test/executor_test.rb +93 -0
- metadata +4 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c1eddd0493e79287abacde548a9237bf6d13c0ec6f5d2e67d802a85de9be29db
|
4
|
+
data.tar.gz: f0826ba6cc0c8066a70d6c7b2cf5f11e5e7d29e1c96915057b4f099546fd1a16
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7f2f82d41dbe9fa17ed1dc184dfaba98fc8e49ced49ec14ba523de6a4965b5ea0d9626d859b9f439647e47c1216a6d2b35a41d0dcb4a4e2d479e83cee736cd91
|
7
|
+
data.tar.gz: f362ca21ed154a5bd0b4ec5e5814cbb1fd48b78a51390bb17fb259d097fd5f12471c7022b85275ee4af3ee88e5efe2cc6dd199256ac6603bb13464c1ef1fe662
|
data/examples/halt.rb
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require_relative 'example_helper'
|
5
|
+
|
6
|
+
example_description = <<DESC
|
7
|
+
|
8
|
+
Halting example
|
9
|
+
===================
|
10
|
+
|
11
|
+
This example shows, how halting works in Dynflow. It spawns a single action,
|
12
|
+
which in turn spawns a few evented actions and a single action which occupies
|
13
|
+
the executor for a long time.
|
14
|
+
|
15
|
+
Once the halt event is sent, the execution plan is halted, suspended steps
|
16
|
+
stay suspended forever, running steps stay running until they actually finish
|
17
|
+
the current run and the execution state is flipped over to stopped state.
|
18
|
+
|
19
|
+
You can see the details at #{ExampleHelper::DYNFLOW_URL}
|
20
|
+
|
21
|
+
DESC
|
22
|
+
|
23
|
+
class EventedCounter < Dynflow::Action
|
24
|
+
def run(event = nil)
|
25
|
+
output[:counter] ||= 0
|
26
|
+
output[:counter] += 1
|
27
|
+
action_logger.info "Iteration #{output[:counter]}"
|
28
|
+
|
29
|
+
if output[:counter] < input[:count]
|
30
|
+
plan_event(:tick, 5)
|
31
|
+
suspend
|
32
|
+
end
|
33
|
+
action_logger.info "Done"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
class Sleeper < Dynflow::Action
|
38
|
+
def run
|
39
|
+
sleep input[:time]
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
class Wrapper < Dynflow::Action
|
44
|
+
def plan
|
45
|
+
sequence do
|
46
|
+
concurrence do
|
47
|
+
5.times { |i| plan_action(EventedCounter, :count => i + 1) }
|
48
|
+
plan_action Sleeper, :time => 20
|
49
|
+
end
|
50
|
+
plan_self
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def run
|
55
|
+
# Noop
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
if $PROGRAM_NAME == __FILE__
|
60
|
+
puts example_description
|
61
|
+
|
62
|
+
ExampleHelper.world.action_logger.level = Logger::DEBUG
|
63
|
+
ExampleHelper.world
|
64
|
+
t = ExampleHelper.world.trigger(Wrapper)
|
65
|
+
Thread.new do
|
66
|
+
sleep 8
|
67
|
+
ExampleHelper.world.halt(t.id)
|
68
|
+
end
|
69
|
+
|
70
|
+
ExampleHelper.run_web_console
|
71
|
+
end
|
data/lib/dynflow/coordinator.rb
CHANGED
@@ -266,6 +266,19 @@ module Dynflow
|
|
266
266
|
end
|
267
267
|
end
|
268
268
|
|
269
|
+
class ExecutionInhibitionLock < Lock
|
270
|
+
def initialize(execution_plan_id)
|
271
|
+
super
|
272
|
+
@data[:owner_id] = "execution-plan:#{execution_plan_id}"
|
273
|
+
@data[:execution_plan_id] = execution_plan_id
|
274
|
+
@data[:id] = self.class.lock_id(execution_plan_id)
|
275
|
+
end
|
276
|
+
|
277
|
+
def self.lock_id(execution_plan_id)
|
278
|
+
"execution-plan:#{execution_plan_id}"
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
269
282
|
class ExecutionLock < LockByWorld
|
270
283
|
def initialize(world, execution_plan_id, client_world_id, request_id)
|
271
284
|
super(world)
|
@@ -13,6 +13,7 @@ module Dynflow
|
|
13
13
|
@execution_plan = Type! execution_plan, ExecutionPlan
|
14
14
|
@future = Type! future, Concurrent::Promises::ResolvableFuture
|
15
15
|
@running_steps_manager = RunningStepsManager.new(world)
|
16
|
+
@halted = false
|
16
17
|
|
17
18
|
unless [:planned, :paused].include? execution_plan.state
|
18
19
|
raise "execution_plan is not in pending or paused state, it's #{execution_plan.state}"
|
@@ -25,6 +26,11 @@ module Dynflow
|
|
25
26
|
start_run or start_finalize or finish
|
26
27
|
end
|
27
28
|
|
29
|
+
def halt
|
30
|
+
@halted = true
|
31
|
+
@running_steps_manager.terminate
|
32
|
+
end
|
33
|
+
|
28
34
|
def restart
|
29
35
|
@run_manager = nil
|
30
36
|
@finalize_manager = nil
|
@@ -72,7 +78,7 @@ module Dynflow
|
|
72
78
|
end
|
73
79
|
|
74
80
|
def done?
|
75
|
-
(!@run_manager || @run_manager.done?) && (!@finalize_manager || @finalize_manager.done?)
|
81
|
+
@halted || (!@run_manager || @run_manager.done?) && (!@finalize_manager || @finalize_manager.done?)
|
76
82
|
end
|
77
83
|
|
78
84
|
def terminate
|
@@ -88,6 +94,7 @@ module Dynflow
|
|
88
94
|
def compute_next_from_step(step)
|
89
95
|
raise "run manager not set" unless @run_manager
|
90
96
|
raise "run manager already done" if @run_manager.done?
|
97
|
+
return [] if @halted
|
91
98
|
|
92
99
|
next_steps = @run_manager.what_is_next(step)
|
93
100
|
if @run_manager.done?
|
@@ -16,6 +16,7 @@ module Dynflow
|
|
16
16
|
# to handle potential updates of the step object (that is part of the event)
|
17
17
|
@events = QueueHash.new(Integer, Director::Event)
|
18
18
|
@events_by_request_id = {}
|
19
|
+
@halted = false
|
19
20
|
end
|
20
21
|
|
21
22
|
def terminate
|
@@ -27,6 +28,10 @@ module Dynflow
|
|
27
28
|
end
|
28
29
|
end
|
29
30
|
|
31
|
+
def halt
|
32
|
+
@halted = true
|
33
|
+
end
|
34
|
+
|
30
35
|
def add(step, work)
|
31
36
|
Type! step, ExecutionPlan::Steps::RunStep
|
32
37
|
@running_steps[step.id] = step
|
@@ -84,6 +89,10 @@ module Dynflow
|
|
84
89
|
event.result.reject UnprocessableEvent.new('step is not suspended, it cannot process events')
|
85
90
|
return []
|
86
91
|
end
|
92
|
+
if @halted
|
93
|
+
event.result.reject UnprocessableEvent.new('execution plan is halted, it cannot receive events')
|
94
|
+
return []
|
95
|
+
end
|
87
96
|
|
88
97
|
can_run_event = @work_items.empty?(step.id)
|
89
98
|
@events_by_request_id[event.request_id] = event
|
data/lib/dynflow/director.rb
CHANGED
@@ -246,8 +246,28 @@ module Dynflow
|
|
246
246
|
end
|
247
247
|
end
|
248
248
|
|
249
|
+
def halt(event)
|
250
|
+
halt_execution(event.execution_plan_id)
|
251
|
+
end
|
252
|
+
|
249
253
|
private
|
250
254
|
|
255
|
+
def halt_execution(execution_plan_id)
|
256
|
+
manager = @execution_plan_managers[execution_plan_id]
|
257
|
+
@logger.warn "Halting execution plan #{execution_plan_id}"
|
258
|
+
return halt_inactive(execution_plan_id) unless manager
|
259
|
+
|
260
|
+
manager.halt
|
261
|
+
finish_manager manager
|
262
|
+
end
|
263
|
+
|
264
|
+
def halt_inactive(execution_plan_id)
|
265
|
+
plan = @world.persistence.load_execution_plan(execution_plan_id)
|
266
|
+
plan.update_state(:stopped)
|
267
|
+
rescue => e
|
268
|
+
@logger.error e
|
269
|
+
end
|
270
|
+
|
251
271
|
def unless_done(manager, work_items)
|
252
272
|
return [] unless manager
|
253
273
|
if manager.done?
|
@@ -310,6 +330,14 @@ module Dynflow
|
|
310
330
|
"cannot execute execution_plan_id:#{execution_plan_id} it's stopped"
|
311
331
|
end
|
312
332
|
|
333
|
+
lock_class = Coordinator::ExecutionInhibitionLock
|
334
|
+
filters = { class: lock_class.to_s, owner_id: lock_class.lock_id(execution_plan_id) }
|
335
|
+
if @world.coordinator.find_records(filters).any?
|
336
|
+
halt_execution(execution_plan_id)
|
337
|
+
raise Dynflow::Error,
|
338
|
+
"cannot execute execution_plan_id:#{execution_plan_id} it's execution is inhibited"
|
339
|
+
end
|
340
|
+
|
313
341
|
@execution_plan_managers[execution_plan_id] =
|
314
342
|
ExecutionPlanManager.new(@world, execution_plan, finished)
|
315
343
|
rescue Dynflow::Error => e
|
@@ -141,6 +141,10 @@ module Dynflow
|
|
141
141
|
ignore_unknown = event.optional
|
142
142
|
find_executor(event.execution_plan_id)
|
143
143
|
end),
|
144
|
+
(on ~Halt do |event|
|
145
|
+
executor = find_executor(event.execution_plan_id)
|
146
|
+
executor == Dispatcher::UnknownWorld ? AnyExecutor : executor
|
147
|
+
end),
|
144
148
|
(on Ping.(~any, ~any) | Status.(~any, ~any) do |receiver_id, _|
|
145
149
|
receiver_id
|
146
150
|
end)
|
@@ -236,7 +240,7 @@ module Dynflow
|
|
236
240
|
(on Execution.(execution_plan_id: ~any) do |uuid|
|
237
241
|
@world.persistence.load_execution_plan(uuid)
|
238
242
|
end),
|
239
|
-
(on Event | Ping do
|
243
|
+
(on Event | Ping | Halt do
|
240
244
|
true
|
241
245
|
end)
|
242
246
|
@tracked_requests.delete(id).success! resolve_to
|
@@ -13,7 +13,8 @@ module Dynflow
|
|
13
13
|
on(Planning) { perform_planning(envelope, envelope.message) },
|
14
14
|
on(Execution) { perform_execution(envelope, envelope.message) },
|
15
15
|
on(Event) { perform_event(envelope, envelope.message) },
|
16
|
-
on(Status) { get_execution_status(envelope, envelope.message) }
|
16
|
+
on(Status) { get_execution_status(envelope, envelope.message) },
|
17
|
+
on(Halt) { halt_execution_plan(envelope, envelope.message) })
|
17
18
|
end
|
18
19
|
|
19
20
|
protected
|
@@ -52,6 +53,11 @@ module Dynflow
|
|
52
53
|
end
|
53
54
|
end
|
54
55
|
|
56
|
+
def halt_execution_plan(envelope, execution_plan_id)
|
57
|
+
@world.executor.halt execution_plan_id
|
58
|
+
respond(envelope, Done)
|
59
|
+
end
|
60
|
+
|
55
61
|
def perform_event(envelope, event_request)
|
56
62
|
future = on_finish do |f|
|
57
63
|
f.then do
|
data/lib/dynflow/dispatcher.rb
CHANGED
@@ -29,7 +29,11 @@ module Dynflow
|
|
29
29
|
execution_plan_id: type { variants String, NilClass }
|
30
30
|
end
|
31
31
|
|
32
|
-
|
32
|
+
Halt = type do
|
33
|
+
fields! execution_plan_id: String, optional: Algebrick::Types::Boolean
|
34
|
+
end
|
35
|
+
|
36
|
+
variants Event, Execution, Ping, Status, Planning, Halt
|
33
37
|
end
|
34
38
|
|
35
39
|
Response = Algebrick.type do
|
@@ -133,7 +133,9 @@ module Dynflow
|
|
133
133
|
telemetry_common_options.merge(:result => key.to_s))
|
134
134
|
end
|
135
135
|
hooks_to_run << key
|
136
|
+
world.persistence.delete_delayed_plans(:execution_plan_uuid => id) if delay_record && original == :scheduled
|
136
137
|
unlock_all_singleton_locks!
|
138
|
+
unlock_execution_inhibition_lock!
|
137
139
|
when :paused
|
138
140
|
unlock_all_singleton_locks!
|
139
141
|
else
|
@@ -566,6 +568,14 @@ module Dynflow
|
|
566
568
|
end
|
567
569
|
end
|
568
570
|
|
571
|
+
def unlock_execution_inhibition_lock!
|
572
|
+
filter = { :owner_id => 'execution-plan:' + self.id,
|
573
|
+
:class => Dynflow::Coordinator::ExecutionInhibitionLock.to_s }
|
574
|
+
world.coordinator.find_locks(filter).each do |lock|
|
575
|
+
world.coordinator.release(lock)
|
576
|
+
end
|
577
|
+
end
|
578
|
+
|
569
579
|
def toggle_telemetry_state(original, new)
|
570
580
|
return if original == new
|
571
581
|
@label = root_plan_step.action_class if @label.nil?
|
data/lib/dynflow/version.rb
CHANGED
@@ -29,12 +29,27 @@ module Dynflow
|
|
29
29
|
end
|
30
30
|
end
|
31
31
|
|
32
|
+
prune_execution_inhibition_locks!
|
33
|
+
|
32
34
|
pruned = persistence.prune_envelopes(world.id)
|
33
35
|
logger.error("Pruned #{pruned} envelopes for invalidated world #{world.id}") unless pruned.zero?
|
34
36
|
coordinator.delete_world(world)
|
35
37
|
end
|
36
38
|
end
|
37
39
|
|
40
|
+
# Prunes execution inhibition locks which got somehow left behind.
|
41
|
+
# Any execution inhibition locks, which have their corresponding execution
|
42
|
+
# plan in stopped state, will be removed.
|
43
|
+
def prune_execution_inhibition_locks!
|
44
|
+
locks = coordinator.find_locks(class: Coordinator::ExecutionInhibitionLock.name)
|
45
|
+
uuids = locks.map { |lock| lock.data[:execution_plan_id] }
|
46
|
+
plan_uuids = persistence.find_execution_plans(filters: { uuid: uuids, state: 'stopped' }).map(&:id)
|
47
|
+
|
48
|
+
locks.select { |lock| plan_uuids.include? lock.data[:execution_plan_id] }.each do |lock|
|
49
|
+
coordinator.release(lock)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
38
53
|
def invalidate_planning_lock(planning_lock)
|
39
54
|
with_valid_execution_plan_for_lock(planning_lock) do |plan|
|
40
55
|
plan.steps.values.each { |step| invalidate_step step }
|
data/lib/dynflow/world.rb
CHANGED
@@ -4,6 +4,7 @@
|
|
4
4
|
require 'dynflow/world/invalidation'
|
5
5
|
|
6
6
|
module Dynflow
|
7
|
+
# rubocop:disable Metrics/ClassLength
|
7
8
|
class World
|
8
9
|
include Algebrick::TypeCheck
|
9
10
|
include Algebrick::Matching
|
@@ -252,6 +253,11 @@ module Dynflow
|
|
252
253
|
publish_request(Dispatcher::Status[world_id, execution_plan_id], done, false, timeout)
|
253
254
|
end
|
254
255
|
|
256
|
+
def halt(execution_plan_id, accepted = Concurrent::Promises.resolvable_future)
|
257
|
+
coordinator.acquire(Coordinator::ExecutionInhibitionLock.new(execution_plan_id))
|
258
|
+
publish_request(Dispatcher::Halt[execution_plan_id], accepted, false)
|
259
|
+
end
|
260
|
+
|
255
261
|
def publish_request(request, done, wait_for_accepted, timeout = nil)
|
256
262
|
accepted = Concurrent::Promises.resolvable_future
|
257
263
|
accepted.rescue do |reason|
|
@@ -390,4 +396,5 @@ module Dynflow
|
|
390
396
|
return actor
|
391
397
|
end
|
392
398
|
end
|
399
|
+
# rubocop:enable Metrics/ClassLength
|
393
400
|
end
|
data/test/executor_test.rb
CHANGED
@@ -718,6 +718,99 @@ module Dynflow
|
|
718
718
|
assert [world.terminate, world.terminate].map(&:value).all?
|
719
719
|
end
|
720
720
|
end
|
721
|
+
|
722
|
+
describe 'halting' do
|
723
|
+
include TestHelpers
|
724
|
+
let(:world) { WorldFactory.create_world }
|
725
|
+
|
726
|
+
it 'halts an execution plan with a suspended step' do
|
727
|
+
triggered = world.trigger(Support::DummyExample::PlanEventsAction, ping_time: 1)
|
728
|
+
plan = world.persistence.load_execution_plan(triggered.id)
|
729
|
+
wait_for do
|
730
|
+
plan = world.persistence.load_execution_plan(triggered.id)
|
731
|
+
plan.state == :running
|
732
|
+
end
|
733
|
+
world.halt(triggered.id)
|
734
|
+
wait_for('the execution plan to halt') do
|
735
|
+
plan = world.persistence.load_execution_plan(triggered.id)
|
736
|
+
plan.state == :stopped
|
737
|
+
end
|
738
|
+
_(plan.steps[2].state).must_equal :suspended
|
739
|
+
end
|
740
|
+
|
741
|
+
it 'halts a paused execution plan' do
|
742
|
+
triggered = world.trigger(Support::DummyExample::FailingDummy)
|
743
|
+
plan = world.persistence.load_execution_plan(triggered.id)
|
744
|
+
wait_for do
|
745
|
+
plan = world.persistence.load_execution_plan(triggered.id)
|
746
|
+
plan.state == :paused
|
747
|
+
end
|
748
|
+
world.halt(plan.id)
|
749
|
+
wait_for('the execution plan to halt') do
|
750
|
+
plan = world.persistence.load_execution_plan(triggered.id)
|
751
|
+
plan.state == :stopped
|
752
|
+
end
|
753
|
+
_(plan.steps[2].state).must_equal :error
|
754
|
+
end
|
755
|
+
|
756
|
+
it 'halts a planned execution plan' do
|
757
|
+
plan = world.plan(Support::DummyExample::Dummy)
|
758
|
+
wait_for do
|
759
|
+
plan = world.persistence.load_execution_plan(plan.id)
|
760
|
+
plan.state == :planned
|
761
|
+
end
|
762
|
+
world.halt(plan.id)
|
763
|
+
wait_for('the execution plan to halt') do
|
764
|
+
plan = world.persistence.load_execution_plan(plan.id)
|
765
|
+
plan.state == :stopped
|
766
|
+
end
|
767
|
+
_(plan.steps[2].state).must_equal :pending
|
768
|
+
end
|
769
|
+
|
770
|
+
it 'halts a scheduled execution plan' do
|
771
|
+
plan = world.delay(Support::DummyExample::Dummy, { start_at: Time.now + 120 })
|
772
|
+
wait_for do
|
773
|
+
plan = world.persistence.load_execution_plan(plan.id)
|
774
|
+
plan.state == :scheduled
|
775
|
+
end
|
776
|
+
world.halt(plan.id)
|
777
|
+
wait_for('the execution plan to halt') do
|
778
|
+
plan = world.persistence.load_execution_plan(plan.id)
|
779
|
+
plan.state == :stopped
|
780
|
+
end
|
781
|
+
_(plan.delay_record).must_be :nil?
|
782
|
+
_(plan.steps[1].state).must_equal :pending
|
783
|
+
end
|
784
|
+
|
785
|
+
it 'halts a pending execution plan' do
|
786
|
+
plan = ExecutionPlan.new(world, nil)
|
787
|
+
plan.save
|
788
|
+
world.halt(plan.id)
|
789
|
+
wait_for('the execution plan to halt') do
|
790
|
+
plan = world.persistence.load_execution_plan(plan.id)
|
791
|
+
plan.state == :stopped
|
792
|
+
end
|
793
|
+
end
|
794
|
+
end
|
795
|
+
|
796
|
+
describe 'execution inhibition locks' do
|
797
|
+
include TestHelpers
|
798
|
+
let(:world) { WorldFactory.create_world }
|
799
|
+
|
800
|
+
it 'inhibits execution' do
|
801
|
+
plan = world.plan(Support::DummyExample::Dummy)
|
802
|
+
world.coordinator.acquire(Coordinator::ExecutionInhibitionLock.new(plan.id))
|
803
|
+
triggered = world.execute(plan.id)
|
804
|
+
triggered.wait
|
805
|
+
_(triggered).must_be :rejected?
|
806
|
+
|
807
|
+
plan = world.persistence.load_execution_plan(plan.id)
|
808
|
+
_(plan.state).must_equal :stopped
|
809
|
+
|
810
|
+
locks = world.coordinator.find_locks({ class: Coordinator::ExecutionInhibitionLock.to_s, owner_id: "execution-plan:#{plan.id}" })
|
811
|
+
_(locks).must_be :empty?
|
812
|
+
end
|
813
|
+
end
|
721
814
|
end
|
722
815
|
end
|
723
816
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: dynflow
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.9.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ivan Necas
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2024-
|
12
|
+
date: 2024-06-11 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: algebrick
|
@@ -412,6 +412,7 @@ files:
|
|
412
412
|
- examples/clock_benchmark.rb
|
413
413
|
- examples/example_helper.rb
|
414
414
|
- examples/future_execution.rb
|
415
|
+
- examples/halt.rb
|
415
416
|
- examples/memory_limit_watcher.rb
|
416
417
|
- examples/orchestrate.rb
|
417
418
|
- examples/orchestrate_evented.rb
|
@@ -682,7 +683,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
682
683
|
- !ruby/object:Gem::Version
|
683
684
|
version: '0'
|
684
685
|
requirements: []
|
685
|
-
rubygems_version: 3.
|
686
|
+
rubygems_version: 3.1.2
|
686
687
|
signing_key:
|
687
688
|
specification_version: 4
|
688
689
|
summary: DYNamic workFLOW engine
|