igniter 0.2.0 → 0.3.1
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/CHANGELOG.md +21 -0
- data/README.md +224 -1
- data/docs/API_V2.md +296 -1
- data/docs/BACKLOG.md +166 -0
- data/docs/BRANCHES_V1.md +213 -0
- data/docs/COLLECTIONS_V1.md +303 -0
- data/docs/EXECUTION_MODEL_V2.md +79 -0
- data/docs/PATTERNS.md +222 -0
- data/docs/STORE_ADAPTERS.md +126 -0
- data/examples/README.md +127 -0
- data/examples/async_store.rb +47 -0
- data/examples/collection.rb +43 -0
- data/examples/collection_partial_failure.rb +50 -0
- data/examples/marketing_ergonomics.rb +57 -0
- data/examples/ringcentral_routing.rb +269 -0
- data/lib/igniter/compiler/compiled_graph.rb +90 -0
- data/lib/igniter/compiler/graph_compiler.rb +12 -2
- data/lib/igniter/compiler/type_resolver.rb +54 -0
- data/lib/igniter/compiler/validation_context.rb +61 -0
- data/lib/igniter/compiler/validation_pipeline.rb +30 -0
- data/lib/igniter/compiler/validator.rb +1 -187
- data/lib/igniter/compiler/validators/callable_validator.rb +107 -0
- data/lib/igniter/compiler/validators/dependencies_validator.rb +153 -0
- data/lib/igniter/compiler/validators/outputs_validator.rb +66 -0
- data/lib/igniter/compiler/validators/type_compatibility_validator.rb +84 -0
- data/lib/igniter/compiler/validators/uniqueness_validator.rb +60 -0
- data/lib/igniter/compiler.rb +8 -0
- data/lib/igniter/contract.rb +152 -4
- data/lib/igniter/diagnostics/auditing/report/console_formatter.rb +80 -0
- data/lib/igniter/diagnostics/auditing/report/markdown_formatter.rb +22 -0
- data/lib/igniter/diagnostics/introspection/formatters/mermaid_formatter.rb +58 -0
- data/lib/igniter/diagnostics/introspection/formatters/text_tree_formatter.rb +44 -0
- data/lib/igniter/diagnostics/report.rb +186 -11
- data/lib/igniter/dsl/contract_builder.rb +271 -5
- data/lib/igniter/dsl/schema_builder.rb +73 -0
- data/lib/igniter/dsl.rb +1 -0
- data/lib/igniter/errors.rb +11 -0
- data/lib/igniter/events/bus.rb +5 -0
- data/lib/igniter/events/event.rb +29 -0
- data/lib/igniter/executor.rb +74 -0
- data/lib/igniter/executor_registry.rb +44 -0
- data/lib/igniter/extensions/auditing/timeline.rb +4 -0
- data/lib/igniter/extensions/introspection/graph_formatter.rb +33 -3
- data/lib/igniter/extensions/introspection/plan_formatter.rb +55 -0
- data/lib/igniter/extensions/introspection/runtime_formatter.rb +18 -3
- data/lib/igniter/extensions/introspection.rb +1 -0
- data/lib/igniter/extensions/reactive/engine.rb +49 -2
- data/lib/igniter/extensions/reactive/reaction.rb +3 -2
- data/lib/igniter/model/branch_node.rb +46 -0
- data/lib/igniter/model/collection_node.rb +31 -0
- data/lib/igniter/model/composition_node.rb +2 -2
- data/lib/igniter/model/compute_node.rb +58 -2
- data/lib/igniter/model/input_node.rb +2 -2
- data/lib/igniter/model/output_node.rb +24 -4
- data/lib/igniter/model.rb +2 -0
- data/lib/igniter/runtime/cache.rb +64 -25
- data/lib/igniter/runtime/collection_result.rb +111 -0
- data/lib/igniter/runtime/deferred_result.rb +40 -0
- data/lib/igniter/runtime/execution.rb +261 -11
- data/lib/igniter/runtime/input_validator.rb +2 -24
- data/lib/igniter/runtime/invalidator.rb +1 -1
- data/lib/igniter/runtime/job_worker.rb +18 -0
- data/lib/igniter/runtime/node_state.rb +20 -0
- data/lib/igniter/runtime/planner.rb +126 -0
- data/lib/igniter/runtime/resolver.rb +310 -15
- data/lib/igniter/runtime/result.rb +14 -2
- data/lib/igniter/runtime/runner_factory.rb +20 -0
- data/lib/igniter/runtime/runners/inline_runner.rb +21 -0
- data/lib/igniter/runtime/runners/store_runner.rb +29 -0
- data/lib/igniter/runtime/runners/thread_pool_runner.rb +37 -0
- data/lib/igniter/runtime/stores/active_record_store.rb +41 -0
- data/lib/igniter/runtime/stores/file_store.rb +43 -0
- data/lib/igniter/runtime/stores/memory_store.rb +40 -0
- data/lib/igniter/runtime/stores/redis_store.rb +44 -0
- data/lib/igniter/runtime.rb +12 -0
- data/lib/igniter/type_system.rb +44 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +23 -0
- metadata +43 -2
data/lib/igniter/contract.rb
CHANGED
|
@@ -7,16 +7,109 @@ module Igniter
|
|
|
7
7
|
@compiled_graph = DSL::ContractBuilder.compile(name: contract_name, &block)
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def
|
|
10
|
+
def run_with(runner:, max_workers: nil)
|
|
11
|
+
@execution_options = { runner: runner, max_workers: max_workers }.compact
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def restore_from_store(execution_id, store: nil)
|
|
15
|
+
snapshot = (store || Igniter.execution_store).fetch(execution_id)
|
|
16
|
+
restore(snapshot)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def resume_from_store(execution_id, token:, value:, store: nil)
|
|
20
|
+
Runtime::JobWorker.new(self, store: store || Igniter.execution_store).resume(
|
|
21
|
+
execution_id: execution_id,
|
|
22
|
+
token: token,
|
|
23
|
+
value: value
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def define_schema(schema)
|
|
28
|
+
@compiled_graph = DSL::SchemaBuilder.compile(schema, name: contract_name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def restore(snapshot)
|
|
32
|
+
instance = new(
|
|
33
|
+
snapshot[:inputs] || snapshot["inputs"] || {},
|
|
34
|
+
runner: snapshot[:runner] || snapshot["runner"],
|
|
35
|
+
max_workers: snapshot[:max_workers] || snapshot["max_workers"]
|
|
36
|
+
)
|
|
37
|
+
instance.restore_execution(snapshot)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def react_to(event_type, path: nil, once_per_execution: false, &block)
|
|
11
41
|
raise CompileError, "react_to requires a block" unless block
|
|
12
42
|
|
|
13
43
|
reactions << Extensions::Reactive::Reaction.new(
|
|
14
44
|
event_type: event_type,
|
|
15
45
|
path: path,
|
|
16
|
-
action: block
|
|
46
|
+
action: block,
|
|
47
|
+
once_per_execution: once_per_execution
|
|
17
48
|
)
|
|
18
49
|
end
|
|
19
50
|
|
|
51
|
+
def effect(path = nil, event_type: :node_succeeded, &block)
|
|
52
|
+
react_to(event_type, path: path, &block)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def on_success(target, &block)
|
|
56
|
+
graph = compiled_graph
|
|
57
|
+
if graph&.output?(target)
|
|
58
|
+
react_to(:execution_finished, once_per_execution: true) do |event:, contract:, execution:|
|
|
59
|
+
next if execution.cache.values.any?(&:failed?)
|
|
60
|
+
next if execution.cache.values.any?(&:pending?)
|
|
61
|
+
|
|
62
|
+
block.call(
|
|
63
|
+
event: event,
|
|
64
|
+
contract: contract,
|
|
65
|
+
execution: execution,
|
|
66
|
+
value: contract.result.public_send(target)
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
else
|
|
70
|
+
effect(target.to_s, event_type: :node_succeeded, &block)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def on_failure(&block)
|
|
75
|
+
react_to(:execution_failed, once_per_execution: true) do |event:, contract:, execution:|
|
|
76
|
+
block.call(
|
|
77
|
+
event: event,
|
|
78
|
+
contract: contract,
|
|
79
|
+
execution: execution,
|
|
80
|
+
status: :failed,
|
|
81
|
+
errors: terminal_errors(execution),
|
|
82
|
+
error: terminal_errors(execution).values.first
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def on_exit(&block)
|
|
88
|
+
terminal_hook = proc do |event:, contract:, execution:|
|
|
89
|
+
status = event.type == :execution_failed ? :failed : :succeeded
|
|
90
|
+
errors = status == :failed ? terminal_errors(execution) : {}
|
|
91
|
+
|
|
92
|
+
block.call(
|
|
93
|
+
event: event,
|
|
94
|
+
contract: contract,
|
|
95
|
+
execution: execution,
|
|
96
|
+
status: status,
|
|
97
|
+
errors: errors,
|
|
98
|
+
error: errors.values.first
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
react_to(:execution_finished, once_per_execution: true, &terminal_hook)
|
|
103
|
+
react_to(:execution_failed, once_per_execution: true, &terminal_hook)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def present(output_name, with: nil, &block)
|
|
107
|
+
raise CompileError, "present requires a block or `with:`" unless block || with
|
|
108
|
+
raise CompileError, "present cannot use both a block and `with:`" if block && with
|
|
109
|
+
|
|
110
|
+
own_output_presenters[output_name.to_sym] = with || block
|
|
111
|
+
end
|
|
112
|
+
|
|
20
113
|
def compiled_graph
|
|
21
114
|
@compiled_graph || superclass_compiled_graph
|
|
22
115
|
end
|
|
@@ -26,8 +119,21 @@ module Igniter
|
|
|
26
119
|
@reactions ||= []
|
|
27
120
|
end
|
|
28
121
|
|
|
122
|
+
def execution_options
|
|
123
|
+
@execution_options || superclass_execution_options || {}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def output_presenters
|
|
127
|
+
inherited = superclass.respond_to?(:output_presenters) ? superclass.output_presenters : {}
|
|
128
|
+
inherited.merge(own_output_presenters)
|
|
129
|
+
end
|
|
130
|
+
|
|
29
131
|
private
|
|
30
132
|
|
|
133
|
+
def own_output_presenters
|
|
134
|
+
@output_presenters ||= {}
|
|
135
|
+
end
|
|
136
|
+
|
|
31
137
|
def contract_name
|
|
32
138
|
name || "AnonymousContract"
|
|
33
139
|
end
|
|
@@ -37,18 +143,47 @@ module Igniter
|
|
|
37
143
|
|
|
38
144
|
superclass.compiled_graph
|
|
39
145
|
end
|
|
146
|
+
|
|
147
|
+
def superclass_execution_options
|
|
148
|
+
return unless superclass.respond_to?(:execution_options)
|
|
149
|
+
|
|
150
|
+
superclass.execution_options
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def terminal_errors(execution)
|
|
154
|
+
execution.cache.values.each_with_object({}) do |state, memo|
|
|
155
|
+
next unless state.failed?
|
|
156
|
+
|
|
157
|
+
memo[state.node.name] = state.error
|
|
158
|
+
end
|
|
159
|
+
end
|
|
40
160
|
end
|
|
41
161
|
|
|
42
162
|
attr_reader :execution, :result
|
|
43
163
|
|
|
44
|
-
def initialize(inputs =
|
|
164
|
+
def initialize(inputs = nil, runner: nil, max_workers: nil, **keyword_inputs)
|
|
45
165
|
graph = self.class.compiled_graph
|
|
46
166
|
raise CompileError, "#{self.class.name} has no compiled graph. Use `define`." unless graph
|
|
47
167
|
|
|
168
|
+
normalized_inputs =
|
|
169
|
+
if inputs.nil?
|
|
170
|
+
keyword_inputs
|
|
171
|
+
elsif keyword_inputs.empty?
|
|
172
|
+
inputs
|
|
173
|
+
else
|
|
174
|
+
inputs.to_h.merge(keyword_inputs)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
execution_options = self.class.execution_options.merge(
|
|
178
|
+
{ runner: runner, max_workers: max_workers }.compact
|
|
179
|
+
)
|
|
180
|
+
execution_options[:store] ||= Igniter.execution_store if execution_options[:runner]&.to_sym == :store
|
|
181
|
+
|
|
48
182
|
@execution = Runtime::Execution.new(
|
|
49
183
|
compiled_graph: graph,
|
|
50
184
|
contract_instance: self,
|
|
51
|
-
inputs:
|
|
185
|
+
inputs: normalized_inputs,
|
|
186
|
+
**execution_options
|
|
52
187
|
)
|
|
53
188
|
@reactive = Extensions::Reactive::Engine.new(
|
|
54
189
|
execution: @execution,
|
|
@@ -98,6 +233,15 @@ module Igniter
|
|
|
98
233
|
Diagnostics::Report.new(execution)
|
|
99
234
|
end
|
|
100
235
|
|
|
236
|
+
def snapshot
|
|
237
|
+
execution.snapshot
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def restore_execution(snapshot)
|
|
241
|
+
execution.restore!(snapshot)
|
|
242
|
+
self
|
|
243
|
+
end
|
|
244
|
+
|
|
101
245
|
def diagnostics_text
|
|
102
246
|
diagnostics.to_text
|
|
103
247
|
end
|
|
@@ -106,6 +250,10 @@ module Igniter
|
|
|
106
250
|
diagnostics.to_markdown
|
|
107
251
|
end
|
|
108
252
|
|
|
253
|
+
def explain_plan(output_names = nil)
|
|
254
|
+
execution.explain_plan(output_names)
|
|
255
|
+
end
|
|
256
|
+
|
|
109
257
|
def success?
|
|
110
258
|
execution.success?
|
|
111
259
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Igniter
|
|
6
|
+
module Diagnostics
|
|
7
|
+
module Auditing
|
|
8
|
+
module Report
|
|
9
|
+
# Generates a human-readable text tree of an audition step.
|
|
10
|
+
class ConsoleFormatter
|
|
11
|
+
def self.call(player)
|
|
12
|
+
new(player).call
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(player)
|
|
16
|
+
@player = player
|
|
17
|
+
@buffer = StringIO.new
|
|
18
|
+
@definition_graph = player.definition_graph
|
|
19
|
+
@audited_graph = player.current_graph
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
print_header
|
|
24
|
+
|
|
25
|
+
@definition_graph.root_nodes.each do |node_def|
|
|
26
|
+
build_node_tree(node_def, prefix: "")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@buffer.string
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def print_header
|
|
35
|
+
trigger = @audited_graph.triggering_event
|
|
36
|
+
@buffer.puts "--- Audition for #{@player.record.contract_class_name} ---"
|
|
37
|
+
@buffer.puts "Step: #{@audited_graph.version} / #{@player.versions_count}"
|
|
38
|
+
@buffer.puts "Trigger: #{trigger[:type]} on '#{trigger[:path]}'" if trigger
|
|
39
|
+
@buffer.puts "-------------------------------------------------"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build_node_tree(node_def, prefix:)
|
|
43
|
+
children_defs = @definition_graph.children_of(node_def)
|
|
44
|
+
is_last = (node_def.parent ? @definition_graph.children_of(node_def.parent) : @definition_graph.root_nodes).last == node_def
|
|
45
|
+
|
|
46
|
+
@buffer << prefix
|
|
47
|
+
@buffer << (is_last ? "└── " : "├── ")
|
|
48
|
+
@buffer << "#{node_def.name} [#{node_def.class.name.split('::').last}]"
|
|
49
|
+
|
|
50
|
+
append_node_details(node_def)
|
|
51
|
+
|
|
52
|
+
@buffer << "\n"
|
|
53
|
+
|
|
54
|
+
new_prefix = prefix + (is_last ? " " : "│ ")
|
|
55
|
+
children_defs.each do |child_def|
|
|
56
|
+
build_node_tree(child_def, prefix: new_prefix)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def append_node_details(node_def)
|
|
61
|
+
audited_node = @audited_graph.find(node_def.path)
|
|
62
|
+
if audited_node
|
|
63
|
+
status_icon = case audited_node.status.to_sym
|
|
64
|
+
when :success then "✓"
|
|
65
|
+
when :failure then "✗"
|
|
66
|
+
when :invalidated then "~"
|
|
67
|
+
else "?"
|
|
68
|
+
end
|
|
69
|
+
@buffer << " [#{status_icon}]"
|
|
70
|
+
@buffer << " => <#{audited_node.value_class}> #{audited_node.value.to_s.truncate(100)}"
|
|
71
|
+
@buffer << " (Errors: #{audited_node.errors.join(', ')})" if audited_node.status.to_sym == :failure
|
|
72
|
+
else
|
|
73
|
+
@buffer << " [Not Present in History]"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Diagnostics
|
|
5
|
+
module Auditing
|
|
6
|
+
module Report
|
|
7
|
+
class MarkdownFormatter
|
|
8
|
+
def self.call(player)
|
|
9
|
+
new(player).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(player)
|
|
13
|
+
@player = player
|
|
14
|
+
@definition_graph = player.definition_graph
|
|
15
|
+
@audited_graph = player.current_graph
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Diagnostics
|
|
5
|
+
module Introspection
|
|
6
|
+
module Formatters
|
|
7
|
+
class MermaidFormatter
|
|
8
|
+
def self.call(structure)
|
|
9
|
+
new(structure).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(structure)
|
|
13
|
+
@structure = structure
|
|
14
|
+
@output = StringIO.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
# Устанавливаем направление диаграммы слева направо
|
|
19
|
+
@output.puts "graph LR"
|
|
20
|
+
@output.puts "classDef default fill:#fff,stroke:#333,stroke-width:2px;"
|
|
21
|
+
|
|
22
|
+
# Запускаем рекурсивную отрисовку
|
|
23
|
+
draw_nodes_and_links(@structure)
|
|
24
|
+
|
|
25
|
+
@output.string
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def draw_nodes_and_links(nodes)
|
|
31
|
+
nodes.each do |node|
|
|
32
|
+
# 1. Если узел - это Namespace, создаем для него subgraph
|
|
33
|
+
if node[:type] == "Namespace"
|
|
34
|
+
@output.puts " subgraph #{node[:name]}"
|
|
35
|
+
# Рекурсивно отрисовываем всех детей внутри подграфа
|
|
36
|
+
draw_nodes_and_links(node[:children])
|
|
37
|
+
@output.puts " end"
|
|
38
|
+
else
|
|
39
|
+
# 2. Если это обычный узел, просто объявляем его
|
|
40
|
+
node_text = "\"#{node[:name]}<br>[#{node[:details]}]\""
|
|
41
|
+
@output.puts " #{node[:path]}(#{node_text})"
|
|
42
|
+
|
|
43
|
+
# У обычного узла тоже могут быть дети (например, проекции у композиции),
|
|
44
|
+
# их тоже нужно отрисовать.
|
|
45
|
+
draw_nodes_and_links(node[:children])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# 3. После объявления узла (или целого подграфа) рисуем его зависимости
|
|
49
|
+
node[:dependencies].each do |dep_path|
|
|
50
|
+
@output.puts " #{dep_path} --> #{node[:path]}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Diagnostics
|
|
5
|
+
module Introspection
|
|
6
|
+
module Formatters
|
|
7
|
+
class TextTreeFormatter
|
|
8
|
+
def self.call(structure)
|
|
9
|
+
new(structure).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(structure)
|
|
13
|
+
@structure = structure
|
|
14
|
+
@output = StringIO.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
@structure.each_with_index do |node, index|
|
|
19
|
+
is_last = index == @structure.size - 1
|
|
20
|
+
print_node(node, "", is_last)
|
|
21
|
+
end
|
|
22
|
+
@output.string
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def print_node(node, prefix, is_last)
|
|
28
|
+
connector = is_last ? "└── " : "├── "
|
|
29
|
+
@output << "#{prefix}#{connector}#{node[:name]} [#{node[:type]}] #{node[:details]}\n"
|
|
30
|
+
|
|
31
|
+
children_prefix = prefix + (is_last ? " " : "│ ")
|
|
32
|
+
if node[:dependencies].any?
|
|
33
|
+
@output << "#{children_prefix} └─ depends_on: [#{node[:dependencies].join(', ')}]\n"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
node[:children].each_with_index do |child, index|
|
|
37
|
+
print_node(child, children_prefix, index == node[:children].size - 1)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|