chore 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.
- data/bin/chore-client +25 -0
- data/bin/chore-server +27 -0
- data/bin/chore-status +28 -0
- data/lib/chore.rb +110 -0
- data/lib/chore/constants.rb +7 -0
- data/lib/chore/server.rb +101 -0
- data/lib/chore/store.rb +137 -0
- data/lib/chore/time_help.rb +26 -0
- metadata +106 -0
data/bin/chore-client
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'chore'
|
4
|
+
require 'chore/constants'
|
5
|
+
require 'trollop'
|
6
|
+
|
7
|
+
opts = Trollop::options do
|
8
|
+
banner "chore-client"
|
9
|
+
|
10
|
+
opt :host, "host name of chore-server", :default => 'localhost'
|
11
|
+
opt :port, "port of chore-server", :default => Chore::Constants::DEFAULT_LISTEN_PORT
|
12
|
+
|
13
|
+
opt :chore, "name of chore", :type => :string
|
14
|
+
opt :action, "action (start, finish, status, fail, etc)", :type => :string
|
15
|
+
end
|
16
|
+
|
17
|
+
Trollop::die :chore, "chore name required" if !opts[:chore]
|
18
|
+
Trollop::die :action, "action required" if !opts[:action]
|
19
|
+
|
20
|
+
server = opts[:host]
|
21
|
+
port = opts[:port]
|
22
|
+
|
23
|
+
Chore.set_server(server, port)
|
24
|
+
|
25
|
+
Chore.public_method(opts[:action]).call(opts[:chore])
|
data/bin/chore-server
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'chore'
|
4
|
+
require 'chore/server'
|
5
|
+
require 'chore/constants'
|
6
|
+
require 'trollop'
|
7
|
+
|
8
|
+
opts = Trollop::options do
|
9
|
+
banner "chore-server"
|
10
|
+
|
11
|
+
opt :listen_port, "Port to listen for client status submissions", :default => Chore::Constants::DEFAULT_LISTEN_PORT
|
12
|
+
opt :cli_port, "Port for chore-status requests", :default => Chore::Constants::DEFAULT_CLI_PORT
|
13
|
+
opt :web_port, "Port for web status server", :default => Chore::Constants::DEFAULT_WEB_PORT
|
14
|
+
end
|
15
|
+
|
16
|
+
|
17
|
+
# TODO: Real command line options instead of this
|
18
|
+
listen_port = opts[:listen_port]
|
19
|
+
cli_port = opts[:cli_port]
|
20
|
+
web_port = opts[:web_port]
|
21
|
+
|
22
|
+
EventMachine::run do
|
23
|
+
EventMachine::PeriodicTimer.new(60) { Chore::Store.expire }
|
24
|
+
EventMachine::open_datagram_socket('0.0.0.0', listen_port, ChoreCollect)
|
25
|
+
EventMachine::start_server('0.0.0.0', cli_port, ChoreDisplay)
|
26
|
+
EventMachine::start_server('0.0.0.0', web_port, ChoreWeb)
|
27
|
+
end
|
data/bin/chore-status
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'socket'
|
4
|
+
require 'chore/constants'
|
5
|
+
require 'trollop'
|
6
|
+
|
7
|
+
opts = Trollop::options do
|
8
|
+
banner "chore-status"
|
9
|
+
|
10
|
+
opt :host, "host name of chore-server", :default => 'localhost'
|
11
|
+
opt :port, "port of chore-server", :default => Chore::Constants::DEFAULT_CLI_PORT
|
12
|
+
end
|
13
|
+
|
14
|
+
server = opts[:host]
|
15
|
+
port = opts[:port]
|
16
|
+
|
17
|
+
begin
|
18
|
+
sock = TCPSocket.open(server,port)
|
19
|
+
sock.puts ".\r\n"
|
20
|
+
|
21
|
+
while next_line = sock.gets
|
22
|
+
puts next_line
|
23
|
+
end
|
24
|
+
|
25
|
+
sock.close
|
26
|
+
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT => ex
|
27
|
+
puts "Couldn't connect to chore-server at #{server}:#{port}"
|
28
|
+
end
|
data/lib/chore.rb
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
require 'socket'
|
2
|
+
require 'json'
|
3
|
+
require 'chore/constants'
|
4
|
+
|
5
|
+
# Client module to access the server. Basic usage is something like:
|
6
|
+
#
|
7
|
+
# Chore.monitor('task') do
|
8
|
+
# # ...
|
9
|
+
# end
|
10
|
+
#
|
11
|
+
# Refer to the various methods for additional options
|
12
|
+
module Chore
|
13
|
+
|
14
|
+
# Override the default server settings
|
15
|
+
def self.set_server ip, port
|
16
|
+
@@server_ip = ip
|
17
|
+
@@server_port = port
|
18
|
+
end
|
19
|
+
|
20
|
+
# Let the server know that you've started a task.
|
21
|
+
# Options you can include are:
|
22
|
+
#
|
23
|
+
# [:do_every] Indicate that the task should run every X seconds.
|
24
|
+
# If this does not happen, show task status in RED.
|
25
|
+
#
|
26
|
+
# [:grace_period] Allow a grace period for the above option. If we
|
27
|
+
# are late but withing the grace period, show task
|
28
|
+
# status in YELLOW.
|
29
|
+
#
|
30
|
+
# [:finish_in] Indicate that the task should finish in X seconds.
|
31
|
+
# If we haven't received a finish message by then,
|
32
|
+
# show the task in RED.
|
33
|
+
#
|
34
|
+
# [:expire_in] Remove the task after X seconds. This may be useful
|
35
|
+
# to keep the task list clean.
|
36
|
+
def self.start task, opts={}
|
37
|
+
opts[:start_time] ||= Time.now().to_i
|
38
|
+
send( [:start, task, opts] )
|
39
|
+
end
|
40
|
+
|
41
|
+
# Provide an optional status message that can be updated.
|
42
|
+
# Only the last status message is retained.
|
43
|
+
def self.status task, message
|
44
|
+
send( [:status_update, task, { :status_note => message}] )
|
45
|
+
end
|
46
|
+
|
47
|
+
# Manually indicate that a task has finished
|
48
|
+
def self.finish task, opts={}
|
49
|
+
opts[:finish_time] ||= Time.now().to_i
|
50
|
+
send( [:finish, task, opts] )
|
51
|
+
end
|
52
|
+
|
53
|
+
# Remove a task from monitoring.
|
54
|
+
def self.pop task, opts={}
|
55
|
+
send( [:pop, task, opts] )
|
56
|
+
end
|
57
|
+
|
58
|
+
# Manually indicate that a task has failed.
|
59
|
+
#
|
60
|
+
# [:error] optional error message
|
61
|
+
def self.fail task, opts={}
|
62
|
+
opts[:fail_time] ||= Time.now().to_i
|
63
|
+
send( [:fail, task, opts] )
|
64
|
+
end
|
65
|
+
|
66
|
+
# Automatically run Chore.start, execute a code block, and
|
67
|
+
# automatically run Chore.finish (or Chore.fail in the case
|
68
|
+
# of an exception) when the block finishes.
|
69
|
+
#
|
70
|
+
# All options from .start, .finish, and .fail may be passed
|
71
|
+
# in as options.
|
72
|
+
#
|
73
|
+
# In addition to normal opts, :pop => true
|
74
|
+
# will automatically remove the task from the store
|
75
|
+
def self.monitor task, opts={}, &code
|
76
|
+
pop = false
|
77
|
+
if opts[:pop]
|
78
|
+
pop = true
|
79
|
+
opts.delete(:pop)
|
80
|
+
end
|
81
|
+
|
82
|
+
Chore.start(task, opts)
|
83
|
+
begin
|
84
|
+
code.call()
|
85
|
+
if pop
|
86
|
+
Chore.pop(task)
|
87
|
+
else
|
88
|
+
Chore.finish(task)
|
89
|
+
end
|
90
|
+
rescue Exception => ex
|
91
|
+
Chore.fail(task, :error => "#{ex.class} - #{ex.message}")
|
92
|
+
raise
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
@@server_ip = '127.0.0.1'
|
99
|
+
@@server_port = Chore::Constants::DEFAULT_LISTEN_PORT
|
100
|
+
|
101
|
+
def self.send msg
|
102
|
+
UDPSocket.new.send(sanitize(msg).to_s, 0, @@server_ip, @@server_port)
|
103
|
+
nil
|
104
|
+
end
|
105
|
+
|
106
|
+
#only allow good options
|
107
|
+
def self.sanitize msg
|
108
|
+
msg.to_json
|
109
|
+
end
|
110
|
+
end
|
data/lib/chore/server.rb
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
require 'eventmachine'
|
2
|
+
require 'evma_httpserver'
|
3
|
+
require 'chore/store'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
# Process submissions from a client and save it in the store.
|
7
|
+
module ChoreCollect
|
8
|
+
@@data_collector = EM.spawn do |chore_info|
|
9
|
+
Chore::Store.update_chore(chore_info)
|
10
|
+
end
|
11
|
+
|
12
|
+
# Sends data to the data_collector spawned process to add
|
13
|
+
# to the data store.
|
14
|
+
def chore_collect chore_info
|
15
|
+
@@data_collector.notify chore_info
|
16
|
+
end
|
17
|
+
|
18
|
+
def receive_data(data)
|
19
|
+
chore_info = JSON.parse(data)
|
20
|
+
chore_collect chore_info
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Provide colorized text output for the CLI interface.
|
25
|
+
module ChoreDisplay
|
26
|
+
def colorize str, color
|
27
|
+
color_code = case color
|
28
|
+
when :red then 31
|
29
|
+
when :green then 32
|
30
|
+
when :yellow then 33
|
31
|
+
else raise "BAD COLOR #{str} #{color}"
|
32
|
+
end
|
33
|
+
"\033[#{color_code}m#{str}\033[0m"
|
34
|
+
end
|
35
|
+
|
36
|
+
def text_statuses
|
37
|
+
|
38
|
+
status_lines = []
|
39
|
+
Chore::Store.iterate_statuses do |status|
|
40
|
+
status_line = "#{status[:job]} - #{status[:status]}ed #{Time.at(status[:start_time])}"
|
41
|
+
status_line += " (#{status[:notes].join(', ')})" if !status[:notes].empty?
|
42
|
+
status_lines << colorize(status_line, status[:state])
|
43
|
+
end
|
44
|
+
|
45
|
+
status_lines.join("\n") + "\n"
|
46
|
+
end
|
47
|
+
|
48
|
+
|
49
|
+
def receive_data(data)
|
50
|
+
send_data(text_statuses)
|
51
|
+
close_connection_after_writing
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# A basic webserver that provides a single web page with chore
|
56
|
+
# statues
|
57
|
+
class ChoreWeb < EventMachine::Connection
|
58
|
+
include EventMachine::HttpServer
|
59
|
+
|
60
|
+
def process_http_request
|
61
|
+
resp = EventMachine::DelegatedHttpResponse.new(self)
|
62
|
+
|
63
|
+
html = <<-html
|
64
|
+
<html>
|
65
|
+
<head>
|
66
|
+
<style type="text/css">
|
67
|
+
body {font-family:monospace;background-color:#CCCCCC;}
|
68
|
+
.red {color:red;}
|
69
|
+
.yellow {color:yellow;}
|
70
|
+
.green {color:green;}
|
71
|
+
table, th, td { border: 1px solid black;}
|
72
|
+
</style>
|
73
|
+
</head>
|
74
|
+
<body>
|
75
|
+
<h1>Chores</h1>
|
76
|
+
<table>
|
77
|
+
<tr><th>Job</th><th>Status</th><th>Time</th><th>Notes</th></tr>
|
78
|
+
html
|
79
|
+
|
80
|
+
Chore::Store.iterate_statuses do |status|
|
81
|
+
row = "<tr class='#{status[:state]}'><td>#{status[:job]}</td><td>#{status[:status]}ed</td><td>#{Time.at(status[:start_time])}</td>"
|
82
|
+
if !status[:notes].empty?
|
83
|
+
row += "<td>(#{status[:notes].join(', ')})</td>"
|
84
|
+
else
|
85
|
+
row += "<td> </td>"
|
86
|
+
end
|
87
|
+
|
88
|
+
row += "</tr>\n"
|
89
|
+
html << row
|
90
|
+
end
|
91
|
+
|
92
|
+
html << "</body></html>"
|
93
|
+
|
94
|
+
resp.status = 200
|
95
|
+
resp.content = html
|
96
|
+
resp.send_response
|
97
|
+
end
|
98
|
+
|
99
|
+
end
|
100
|
+
|
101
|
+
|
data/lib/chore/store.rb
ADDED
@@ -0,0 +1,137 @@
|
|
1
|
+
require 'eventmachine'
|
2
|
+
require 'chore/time_help'
|
3
|
+
|
4
|
+
module Chore
|
5
|
+
# A semi-persistant store for all of our chore data. Right now
|
6
|
+
# it's just a hash that won't survive a server restart.
|
7
|
+
module Store
|
8
|
+
#
|
9
|
+
# Process data with a spawned process in the background
|
10
|
+
#
|
11
|
+
|
12
|
+
def self.update_chore chore_info
|
13
|
+
state = chore_info[0]
|
14
|
+
chore = chore_info[1]
|
15
|
+
opts = chore_info[2]
|
16
|
+
opts['status'] = state
|
17
|
+
|
18
|
+
if state == "pop"
|
19
|
+
Store.get.delete(chore)
|
20
|
+
else
|
21
|
+
if Store.get[chore].nil?
|
22
|
+
Store.get[chore] = {}
|
23
|
+
end
|
24
|
+
|
25
|
+
Store.get[chore] = Store.get[chore].merge(opts)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Remove anything that's currently expired from the store.
|
30
|
+
def self.expire
|
31
|
+
expired_tasks = []
|
32
|
+
|
33
|
+
Chore::Store.get.each_pair do |task, params|
|
34
|
+
if params['expire_in']
|
35
|
+
start_time = params['start_time'].to_i
|
36
|
+
expire_in = params['expire_in'].to_i
|
37
|
+
expire_time = start_time + expire_in
|
38
|
+
|
39
|
+
if expire_time < Time.now().to_i
|
40
|
+
expired_tasks << task
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
expired_tasks.each do |task|
|
46
|
+
Chore::Store.get.delete(task)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# get status of a single chore
|
51
|
+
def self.get_chore chore_name
|
52
|
+
chore_name = chore_name.to_s
|
53
|
+
chore_data = Store.get[chore_name]
|
54
|
+
|
55
|
+
return nil if chore_data.nil?
|
56
|
+
|
57
|
+
build_status(chore_name, chore_data)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Climb through the internal store and return a processed and
|
61
|
+
# abstracted list of tasks to the consumer.
|
62
|
+
def self.iterate_statuses
|
63
|
+
ret = []
|
64
|
+
Store.get.keys.each do |chore_name|
|
65
|
+
yield get_chore(chore_name)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
@@store = {}
|
71
|
+
|
72
|
+
def self.get
|
73
|
+
@@store
|
74
|
+
end
|
75
|
+
|
76
|
+
def self.build_status chore_name, status_info
|
77
|
+
status = status_info['status'].to_sym
|
78
|
+
run_time = status_info['start_time']
|
79
|
+
run_time = 0 if !run_time
|
80
|
+
|
81
|
+
current_time = Time.now.to_i
|
82
|
+
do_every = status_info['do_every']
|
83
|
+
grace_period = status_info['grace_period']
|
84
|
+
|
85
|
+
notes = []
|
86
|
+
state = :red
|
87
|
+
|
88
|
+
if status == :fail
|
89
|
+
state = :red
|
90
|
+
if status_info['error']
|
91
|
+
notes << status_info['error']
|
92
|
+
else
|
93
|
+
notes << "FAILED!!!"
|
94
|
+
end
|
95
|
+
|
96
|
+
elsif status == :finish
|
97
|
+
finish_time = status_info['finish_time']
|
98
|
+
finish_in = status_info['finish_in']
|
99
|
+
|
100
|
+
if finish_in.nil?
|
101
|
+
state = :green
|
102
|
+
elsif (run_time + finish_in) >= finish_time
|
103
|
+
state = :green
|
104
|
+
else
|
105
|
+
state = :red
|
106
|
+
notes << "Finished, but #{finish_time - (run_time + finish_in)} seconds late!!!"
|
107
|
+
end
|
108
|
+
elsif status == :start || status == :status_update
|
109
|
+
if do_every
|
110
|
+
if run_time + do_every >= current_time
|
111
|
+
state = :green
|
112
|
+
notes << "Should run every #{Chore::TimeHelp.elapsed_human_time(do_every)}"
|
113
|
+
elsif grace_period && run_time + do_every + grace_period > current_time
|
114
|
+
state = :yellow
|
115
|
+
notes << "Job should run every #{Chore::TimeHelp.elapsed_human_time(do_every)}, but has a grace period of #{Chore::TimeHelp.elapsed_human_time(grace_period)}"
|
116
|
+
else
|
117
|
+
state = :red
|
118
|
+
notes << "Job should run every #{Chore::TimeHelp.elapsed_human_time(do_every)}, but hasn't run since #{Time.at(run_time)}"
|
119
|
+
end
|
120
|
+
else
|
121
|
+
state = :green
|
122
|
+
end
|
123
|
+
|
124
|
+
if status_info['expire_in']
|
125
|
+
expire_in = Time.at(status_info['start_time'] + status_info['expire_in'].to_i)
|
126
|
+
notes << "Will expire in #{expire_in}"
|
127
|
+
end
|
128
|
+
|
129
|
+
notes << "Status: #{status_info['status_note']}" if status_info['status_note']
|
130
|
+
end
|
131
|
+
|
132
|
+
info = {:job => chore_name, :state => state, :status => status, :start_time => run_time, :notes => notes}
|
133
|
+
end
|
134
|
+
|
135
|
+
|
136
|
+
end
|
137
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Chore
|
2
|
+
module TimeHelp
|
3
|
+
# Show stuff like "7 weeks, 3 days, 4 hours" instead of
|
4
|
+
# 13252363477 seconds since epoch
|
5
|
+
def self.elapsed_human_time seconds
|
6
|
+
remaining_ticks = seconds
|
7
|
+
human_text = ""
|
8
|
+
|
9
|
+
[[60,:seconds],[60,:minutes],[24,:hours],[7, :days]].each do |ticks, unit|
|
10
|
+
above = remaining_ticks / ticks
|
11
|
+
below = remaining_ticks % ticks
|
12
|
+
|
13
|
+
if below != 0
|
14
|
+
unit = unit[0..-2] if below == 1
|
15
|
+
human_text = "#{below} #{unit} " + human_text
|
16
|
+
end
|
17
|
+
|
18
|
+
remaining_ticks = above
|
19
|
+
if above == 0
|
20
|
+
break
|
21
|
+
end
|
22
|
+
end
|
23
|
+
human_text.strip
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
metadata
ADDED
@@ -0,0 +1,106 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: chore
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease:
|
5
|
+
version: 0.2.0
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Pikimal, LLC
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
|
13
|
+
date: 2012-06-15 00:00:00 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: eventmachine
|
17
|
+
prerelease: false
|
18
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
19
|
+
none: false
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: 0.2.10
|
24
|
+
type: :runtime
|
25
|
+
version_requirements: *id001
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: eventmachine_httpserver
|
28
|
+
prerelease: false
|
29
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
30
|
+
none: false
|
31
|
+
requirements:
|
32
|
+
- - ">="
|
33
|
+
- !ruby/object:Gem::Version
|
34
|
+
version: 0.2.1
|
35
|
+
type: :runtime
|
36
|
+
version_requirements: *id002
|
37
|
+
- !ruby/object:Gem::Dependency
|
38
|
+
name: trollop
|
39
|
+
prerelease: false
|
40
|
+
requirement: &id003 !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ">="
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: 1.16.2
|
46
|
+
type: :runtime
|
47
|
+
version_requirements: *id003
|
48
|
+
- !ruby/object:Gem::Dependency
|
49
|
+
name: rspec
|
50
|
+
prerelease: false
|
51
|
+
requirement: &id004 !ruby/object:Gem::Requirement
|
52
|
+
none: false
|
53
|
+
requirements:
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: "0"
|
57
|
+
type: :development
|
58
|
+
version_requirements: *id004
|
59
|
+
description: Monitor chorse
|
60
|
+
email: grant@pikimal.com
|
61
|
+
executables:
|
62
|
+
- chore-server
|
63
|
+
- chore-status
|
64
|
+
- chore-client
|
65
|
+
extensions: []
|
66
|
+
|
67
|
+
extra_rdoc_files: []
|
68
|
+
|
69
|
+
files:
|
70
|
+
- lib/chore.rb
|
71
|
+
- lib/chore/server.rb
|
72
|
+
- lib/chore/time_help.rb
|
73
|
+
- lib/chore/store.rb
|
74
|
+
- lib/chore/constants.rb
|
75
|
+
- bin/chore-server
|
76
|
+
- bin/chore-status
|
77
|
+
- bin/chore-client
|
78
|
+
homepage: http://github.com/pikimal/chore
|
79
|
+
licenses: []
|
80
|
+
|
81
|
+
post_install_message:
|
82
|
+
rdoc_options: []
|
83
|
+
|
84
|
+
require_paths:
|
85
|
+
- lib
|
86
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
87
|
+
none: false
|
88
|
+
requirements:
|
89
|
+
- - ">="
|
90
|
+
- !ruby/object:Gem::Version
|
91
|
+
version: "0"
|
92
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
93
|
+
none: false
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: "0"
|
98
|
+
requirements: []
|
99
|
+
|
100
|
+
rubyforge_project:
|
101
|
+
rubygems_version: 1.8.11
|
102
|
+
signing_key:
|
103
|
+
specification_version: 3
|
104
|
+
summary: Monitor chores
|
105
|
+
test_files: []
|
106
|
+
|