Browse Source

Merge remote-tracking branch 'knu/scheduler_agent'

Conflicts:
	db/schema.rb
	spec/lib/huginn_scheduler_spec.rb
Akinori MUSHA 10 years ago
parent
commit
10ce9d598b

+ 6 - 0
.env.example

@@ -118,6 +118,12 @@ ALLOW_JSONPATH_EVAL=false
 # when you trust everyone using your Huginn installation.
 ENABLE_INSECURE_AGENTS=false
 
+# Enable this setting to allow second precision schedule in
+# SchedulerAgent.  By default, the use of the "second" field is
+# restricted so that any value other than a single zero (which means
+# "on the minute") is disallowed to prevent abuse of service.
+ENABLE_SECOND_PRECISION_SCHEDULE=false
+
 # Use Graphviz for generating diagrams instead of using Google Chart
 # Tools.  Specify a dot(1) command path built with SVG support
 # enabled.

+ 19 - 3
app/assets/javascripts/application.js.coffee.erb

@@ -23,14 +23,13 @@ window.setupJsonEditor = ($editors = $(".live-json-editor")) ->
   return editors
 
 hideSchedule = ->
-  $(".schedule-region select").hide()
+  $(".schedule-region .can-be-scheduled").hide()
   $(".schedule-region .cannot-be-scheduled").show()
 
 showSchedule = (defaultSchedule = null) ->
-  $(".schedule-region select").show()
   if defaultSchedule?
     $(".schedule-region select").val(defaultSchedule).change()
-  $(".schedule-region select").show()
+  $(".schedule-region .can-be-scheduled").show()
   $(".schedule-region .cannot-be-scheduled").hide()
 
 hideLinks = ->
@@ -44,6 +43,12 @@ showLinks = ->
   $(".link-region .cannot-receive-events").hide()
   showEventDescriptions()
 
+hideControlLinks = ->
+  $(".control-link-region").hide()
+
+showControlLinks = ->
+  $(".control-link-region").show()
+
 hideEventCreation = ->
   $(".event-related-region").hide()
 
@@ -162,6 +167,11 @@ $(document).ready ->
         else
           hideLinks()
 
+        if json.can_control_other_agents
+          showControlLinks()
+        else
+          hideControlLinks()
+
         if json.can_create_events
           showEventCreation()
         else
@@ -194,6 +204,12 @@ $(document).ready ->
     else
       hideLinks()
 
+  if $(".control-link-region")
+    if $(".control-link-region").data("can-control-other-agents") == true
+      showControlLinks()
+    else
+      hideControlLinks()
+
   if $(".event-related-region")
     if $(".event-related-region").data("can-create-events") == true
       showEventCreation()

+ 4 - 0
app/assets/stylesheets/application.css.scss.erb

@@ -60,6 +60,10 @@ img.odin {
   display: none;
 }
 
+.controller-region[data-has-controllers=false] {
+  display: none;
+}
+
 img.spinner {
   display: none;
   vertical-align: bottom;

+ 51 - 0
app/concerns/agent_controller_concern.rb

@@ -0,0 +1,51 @@
+module AgentControllerConcern
+  extend ActiveSupport::Concern
+
+  included do
+    validate :validate_control_action
+  end
+
+  def default_options
+    {
+      'action' => 'run',
+    }
+  end
+
+  def control_action
+    options['action'].presence || 'run'
+  end
+
+  def validate_control_action
+    case control_action
+    when 'run'
+      control_targets.each { |target|
+        if target.cannot_be_scheduled?
+          errors.add(:base, "#{target.name} cannot be scheduled")
+        end
+      }
+    when 'enable', 'disable'
+    else
+      errors.add(:base, 'invalid action')
+    end
+  end
+
+  def control!
+    control_targets.active.each { |target|
+      begin
+        case control_action
+        when 'run'
+          log "Agent run queued for '#{target.name}'"
+          Agent.async_check(target.id)
+        when 'enable'
+          log "Enabling the Agent '#{target.name}'"
+          target.update!(disable: false) if target.disabled?
+        when 'disable'
+          log "Disabling the Agent '#{target.name}'"
+          target.update!(disable: true) unless target.disabled?
+        end
+      rescue => e
+        error "Failed to #{control_action} '#{target.name}': #{e.message}"
+      end
+    }
+  end
+end

+ 1 - 0
app/controllers/agents_controller.rb

@@ -37,6 +37,7 @@ class AgentsController < ApplicationController
         :default_schedule => @agent.default_schedule,
         :can_receive_events => @agent.can_receive_events?,
         :can_create_events => @agent.can_create_events?,
+        :can_control_other_agents => @agent.can_control_other_agents?,
         :options => @agent.default_options,
         :description_html => @agent.html_description,
         :form => render_to_string(partial: 'oauth_dropdown')

+ 22 - 0
app/helpers/agent_helper.rb

@@ -15,4 +15,26 @@ module AgentHelper
   def agent_show_class(agent)
     agent.short_type.underscore.dasherize
   end
+
+  def agent_schedule(agent, delimiter = ', ')
+    return 'n/a' unless agent.can_be_scheduled?
+
+    case agent.schedule
+    when nil, 'never'
+      agent_controllers(agent, delimiter) || 'Never'
+    else
+      [
+        agent.schedule.humanize.titleize,
+        *(agent_controllers(agent, delimiter))
+      ].join(delimiter).html_safe
+    end
+  end
+
+  def agent_controllers(agent, delimiter = ', ')
+    unless agent.controllers.empty?
+      agent.controllers.map { |agent|
+        link_to(agent.name, agent_path(agent))
+      }.join(delimiter).html_safe
+    end
+  end
 end

+ 11 - 2
app/helpers/dot_helper.rb

@@ -138,7 +138,9 @@ module DotHelper
       def agent_edge(agent, receiver)
         edge(agent_id[agent],
              agent_id[receiver],
-             style: ('dashed' unless receiver.propagate_immediately),
+             style: ('dashed' unless receiver.propagate_immediately?),
+             label: (" #{agent.control_action}s " if agent.can_control_other_agents?),
+             arrowhead: ('empty' if agent.can_control_other_agents?),
              color: (@disabled if agent.disabled? || receiver.disabled?))
       end
 
@@ -151,10 +153,17 @@ module DotHelper
                   fontsize: 10,
                   fontname: ('Helvetica' if rich)
 
+        statement 'edge',
+                  fontsize: 10,
+                  fontname: ('Helvetica' if rich)
+
         agents.each.with_index { |agent, index|
           agent_node(agent)
 
-          agent.receivers.each { |receiver|
+          [
+            *agent.receivers,
+            *(agent.control_targets if agent.can_control_other_agents?)
+          ].each { |receiver|
             agent_edge(agent, receiver) if agents.include?(receiver)
           }
         }

+ 29 - 4
app/models/agent.rb

@@ -25,13 +25,15 @@ class Agent < ActiveRecord::Base
 
   EVENT_RETENTION_SCHEDULES = [["Forever", 0], ["1 day", 1], *([2, 3, 4, 5, 7, 14, 21, 30, 45, 90, 180, 365].map {|n| ["#{n} days", n] })]
 
-  attr_accessible :options, :memory, :name, :type, :schedule, :disabled, :source_ids, :scenario_ids, :keep_events_for, :propagate_immediately
+  attr_accessible :options, :memory, :name, :type, :schedule, :controller_ids, :control_target_ids, :disabled, :source_ids, :scenario_ids, :keep_events_for, :propagate_immediately
 
   json_serialize :options, :memory
 
   validates_presence_of :name, :user
   validates_inclusion_of :keep_events_for, :in => EVENT_RETENTION_SCHEDULES.map(&:last)
   validate :sources_are_owned
+  validate :controllers_are_owned
+  validate :control_targets_are_owned
   validate :scenarios_are_owned
   validate :validate_schedule
   validate :validate_options
@@ -53,6 +55,10 @@ class Agent < ActiveRecord::Base
   has_many :links_as_receiver, :dependent => :delete_all, :foreign_key => "receiver_id", :class_name => "Link", :inverse_of => :receiver
   has_many :sources, :through => :links_as_receiver, :class_name => "Agent", :inverse_of => :receivers
   has_many :receivers, :through => :links_as_source, :class_name => "Agent", :inverse_of => :sources
+  has_many :control_links_as_controller, dependent: :delete_all, foreign_key: 'controller_id', class_name: 'ControlLink', inverse_of: :controller
+  has_many :control_links_as_control_target, dependent: :delete_all, foreign_key: 'control_target_id', class_name: 'ControlLink', inverse_of: :control_target
+  has_many :controllers, through: :control_links_as_control_target, class_name: "Agent", inverse_of: :control_targets
+  has_many :control_targets, through: :control_links_as_controller, class_name: "Agent", inverse_of: :controllers
   has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :agent
   has_many :scenarios, :through => :scenario_memberships, :inverse_of => :agents
 
@@ -175,6 +181,10 @@ class Agent < ActiveRecord::Base
     !cannot_create_events?
   end
 
+  def can_control_other_agents?
+    self.class.can_control_other_agents?
+  end
+
   def log(message, options = {})
     puts "Agent##{id}: #{message}" unless Rails.env.test?
     AgentLog.log_for_agent(self, message, options)
@@ -214,11 +224,19 @@ class Agent < ActiveRecord::Base
   private
   
   def sources_are_owned
-    errors.add(:sources, "must be owned by you") unless sources.all? {|s| s.user == user }
+    errors.add(:sources, "must be owned by you") unless sources.all? {|s| s.user_id == user_id }
   end
   
+  def controllers_are_owned
+    errors.add(:controllers, "must be owned by you") unless controllers.all? {|s| s.user_id == user_id }
+  end
+
+  def control_targets_are_owned
+    errors.add(:control_targets, "must be owned by you") unless control_targets.all? {|s| s.user_id == user_id }
+  end
+
   def scenarios_are_owned
-    errors.add(:scenarios, "must be owned by you") unless scenarios.all? {|s| s.user == user }
+    errors.add(:scenarios, "must be owned by you") unless scenarios.all? {|s| s.user_id == user_id }
   end
 
   def validate_schedule
@@ -248,7 +266,8 @@ class Agent < ActiveRecord::Base
 
   class << self
     def build_clone(original)
-      new(original.slice(:type, :options, :schedule, :source_ids, :keep_events_for, :propagate_immediately)) { |clone|
+      new(original.slice(:type, :options, :schedule, :controller_ids, :control_target_ids,
+                         :source_ids, :keep_events_for, :propagate_immediately)) { |clone|
         # Give it a unique name
         2.upto(count) do |i|
           name = '%s (%d)' % [original.name, i]
@@ -289,6 +308,10 @@ class Agent < ActiveRecord::Base
       !!@cannot_receive_events
     end
 
+    def can_control_other_agents?
+      include? AgentControllerConcern
+    end
+
     # Find all Agents that have received Events since the last execution of this method.  Update those Agents with
     # their new `last_checked_event_id` and queue each of the Agents to be called with #receive using `async_receive`.
     # This is called by bin/schedule.rb periodically.
@@ -398,6 +421,8 @@ class AgentDrop
     :sources,
     :receivers,
     :schedule,
+    :controllers,
+    :control_targets,
     :disabled,
     :keep_events_for,
     :propagate_immediately,

+ 113 - 0
app/models/agents/scheduler_agent.rb

@@ -0,0 +1,113 @@
+require 'rufus-scheduler'
+
+module Agents
+  class SchedulerAgent < Agent
+    include AgentControllerConcern
+
+    cannot_be_scheduled!
+    cannot_receive_events!
+    cannot_create_events!
+
+    @@second_precision_enabled = ENV['ENABLE_SECOND_PRECISION_SCHEDULE'] == 'true'
+
+    cattr_reader :second_precision_enabled
+
+    description <<-MD
+      This agent periodically takes an action on target Agents according to a user-defined schedule.
+
+      # Action types
+
+      Set `action` to one of the action types below:
+
+      * `run`: This is the default.  Target Agents are run at intervals.
+
+      * `disable`: Target Agents are disabled (if not) at intervals.
+
+      * `enable`: Target Agents are enabled (if not) at intervals.
+
+      # Targets
+
+      Select Agents that you want to run periodically by this SchedulerAgent.
+
+      # Schedule
+
+      Set `schedule` to a schedule specification in the [cron](http://en.wikipedia.org/wiki/Cron) format.
+      For example:
+
+      * `0 22 * * 1-5`: every day of the week at 22:00 (10pm)
+
+      * `*/10 8-11 * * *`: every 10 minutes from 8:00 to and not including 12:00
+
+      This variant has several extensions as explained below.
+
+      ## Timezones
+
+      You can optionally specify a timezone (default: `#{Time.zone.name}`) after the day-of-week field.
+
+      * `0 22 * * 1-5 Europe/Paris`: every day of the week when it's 22:00 in Paris
+
+      * `0 22 * * 1-5 Etc/GMT+2`: every day of the week when it's 22:00 in GMT+2
+
+      ## Seconds
+
+      You can optionally specify seconds before the minute field.
+
+      * `*/30 * * * * *`: every 30 seconds
+
+      #{"Only multiples of fifteen are allowed as values for the seconds field, i.e. `*/15`, `*/30`, `15,45` etc." unless second_precision_enabled}
+
+      ## Last day of month
+
+      `L` signifies "last day of month" in `day-of-month`.
+
+      * `0 22 L * *`: every month on the last day at 22:00
+
+      ## Weekday names
+
+      You can use three letter names instead of numbers in the `weekdays` field.
+
+      * `0 22 * * Sat,Sun`: every Saturday and Sunday, at 22:00
+
+      ## Nth weekday of the month
+
+      You can specify "nth weekday of the month" like this.
+
+      * `0 22 * * Sun#1,Sun#2`: every first and second Sunday of the month, at 22:00
+
+      * `0 22 * * Sun#L1`: every last Sunday of the month, at 22:00
+    MD
+
+    def default_options
+      super.update({
+        'schedule' => '0 * * * *',
+      })
+    end
+
+    def working?
+      true
+    end
+
+    def check!
+      control!
+    end
+
+    def validate_options
+      if (spec = options['schedule']).present?
+        begin
+          cron = Rufus::Scheduler::CronLine.new(spec)
+          unless second_precision_enabled || (cron.seconds - [0, 15, 30, 45, 60]).empty?
+            errors.add(:base, "second precision schedule is not allowed in this service")
+          end
+        rescue ArgumentError
+          errors.add(:base, "invalid schedule")
+        end
+      else
+        errors.add(:base, "schedule is missing")
+      end
+    end
+
+    before_save do
+      self.memory.delete('scheduled_at') if self.options_changed?
+    end
+  end
+end

+ 7 - 0
app/models/control_link.rb

@@ -0,0 +1,7 @@
+# A ControlLink connects Agents in a control flow from the `controller` to the `control_target`.
+class ControlLink < ActiveRecord::Base
+  attr_accessible :controller_id, :target_id
+
+  belongs_to :controller, class_name: 'Agent', inverse_of: :control_links_as_controller
+  belongs_to :control_target, class_name: 'Agent', inverse_of: :control_links_as_control_target
+end

+ 26 - 1
app/views/agents/_form.html.erb

@@ -37,11 +37,36 @@
           <div class="form-group">
             <%= f.label :schedule, :class => 'control-label' %>
             <div class="schedule-region" data-can-be-scheduled="<%= @agent.can_be_scheduled? %>">
-              <%= f.select :schedule, options_for_select(Agent::SCHEDULES.map {|s| [s.humanize.titleize, s] }, @agent.schedule), {}, :class => 'form-control' %>
+              <div class="can-be-scheduled">
+                <%= f.select :schedule, options_for_select(Agent::SCHEDULES.map {|s| [s.humanize.titleize, s] }, @agent.schedule), {}, :class => 'form-control' %>
+              </div>
               <span class='cannot-be-scheduled text-info'>This type of Agent cannot be scheduled.</span>
             </div>
           </div>
 
+          <div class="controller-region" data-has-controllers="<%= !@agent.controllers.empty? %>">
+            <div class="form-group">
+              <%= f.label :controllers %>
+              <span class="glyphicon glyphicon-question-sign hover-help" data-content="Other than the system-defined schedule above, this agent may be run or controlled by these user-defined Agents."></span>
+              <div class="controller-list">
+                <%= agent_controllers(@agent) || 'None' %>
+              </div>
+            </div>
+          </div>
+
+          <div class="control-link-region" data-can-control-other-agents="<%= @agent.can_control_other_agents? %>">
+            <div class="can-control-other-agents">
+              <div class="form-group">
+                <%= f.label :control_targets %>
+                <% eventControlTargets = current_user.agents.select(&:can_be_scheduled?) %>
+                <%= f.select(:control_target_ids,
+                             options_for_select(eventControlTargets.map {|s| [s.name, s.id] },
+                                                @agent.control_target_ids),
+                             {}, { multiple: true, size: 5, class: 'select2 form-control' }) %>
+              </div>
+            </div>
+          </div>
+
           <div class='event-related-region' data-can-create-events="<%= @agent.can_create_events? %>">
             <div class="form-group">
               <%= f.label :keep_events_for, "Keep events" %>

+ 1 - 1
app/views/agents/_table.html.erb

@@ -25,7 +25,7 @@
         </td>
         <td class='<%= "agent-disabled" if agent.disabled? %>'>
           <% if agent.can_be_scheduled? %>
-            <%= agent.schedule.to_s.humanize.titleize %>
+            <%= agent_schedule(agent, ',<br/>') %>
           <% else %>
             <span class='not-applicable'></span>
           <% end %>

+ 12 - 1
app/views/agents/show.html.erb

@@ -72,7 +72,7 @@
             <% if @agent.can_be_scheduled? %>
               <p>
                 <b>Schedule:</b>
-                <%= (@agent.schedule || "n/a").humanize.titleize %>
+                <%= agent_schedule(@agent) %>
               </p>
 
               <p>
@@ -134,6 +134,17 @@
               </p>
             <% end %>
 
+            <% if @agent.can_control_other_agents? %>
+              <p>
+                <b>Control Targets:</b>
+                <% if (agents = @agent.control_targets).length > 0 %>
+                  <%= agents.map { |agent| link_to(agent.name, agent_path(agent)) }.to_sentence.html_safe %>
+                <% else %>
+                  None
+                <% end %>
+              </p>
+            <% end %>
+
             <p>
               <b>Working:</b>
               <%= working @agent %>

+ 13 - 0
db/migrate/20140901143732_add_control_links.rb

@@ -0,0 +1,13 @@
+class AddControlLinks < ActiveRecord::Migration
+  def change
+    create_table :control_links do |t|
+      t.integer :controller_id, null: false
+      t.integer :control_target_id, null: false
+
+      t.timestamps
+    end
+
+    add_index :control_links, [:controller_id, :control_target_id], unique: true
+    add_index :control_links, :control_target_id
+  end
+end

+ 12 - 2
db/schema.rb

@@ -11,7 +11,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20140906030139) do
+ActiveRecord::Schema.define(version: 20140901143732) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -40,7 +40,7 @@ ActiveRecord::Schema.define(version: 20140906030139) do
     t.datetime "updated_at",                                               null: false
     t.text     "memory",                limit: 2147483647,                              charset: "utf8mb4", collation: "utf8mb4_bin"
     t.datetime "last_web_request_at"
-    t.integer  "keep_events_for",       default: 0,     null: false
+    t.integer  "keep_events_for",                          default: 0,     null: false
     t.datetime "last_event_at"
     t.datetime "last_error_log_at"
     t.boolean  "propagate_immediately", default: false, null: false
@@ -54,6 +54,16 @@ ActiveRecord::Schema.define(version: 20140906030139) do
   add_index "agents", ["type"], name: "index_agents_on_type", using: :btree
   add_index "agents", ["user_id", "created_at"], name: "index_agents_on_user_id_and_created_at", using: :btree
 
+  create_table "control_links", force: true do |t|
+    t.integer  "controller_id",     null: false
+    t.integer  "control_target_id", null: false
+    t.datetime "created_at"
+    t.datetime "updated_at"
+  end
+
+  add_index "control_links", ["control_target_id"], name: "index_control_links_on_control_target_id", using: :btree
+  add_index "control_links", ["controller_id", "control_target_id"], name: "index_control_links_on_controller_id_and_control_target_id", unique: true, using: :btree
+
   create_table "delayed_jobs", force: true do |t|
     t.integer  "priority",                    default: 0
     t.integer  "attempts",                    default: 0

+ 98 - 0
lib/huginn_scheduler.rb

@@ -1,5 +1,97 @@
 require 'rufus/scheduler'
 
+class Rufus::Scheduler
+  SCHEDULER_AGENT_TAG = Agents::SchedulerAgent.name
+
+  class Job
+    # Store an ID of SchedulerAgent in this job.
+    def scheduler_agent_id=(id)
+      self[:scheduler_agent_id] = id
+    end
+
+    # Extract an ID of SchedulerAgent if any.
+    def scheduler_agent_id
+      self[:scheduler_agent_id]
+    end
+
+    # Return a SchedulerAgent tied to this job.  Return nil if it is
+    # not found or disabled.
+    def scheduler_agent
+      agent_id = scheduler_agent_id or return nil
+
+      Agent.of_type(Agents::SchedulerAgent).active.find_by(id: agent_id)
+    end
+  end
+
+  # Get all jobs tied to any SchedulerAgent
+  def scheduler_agent_jobs
+    jobs(tag: SCHEDULER_AGENT_TAG)
+  end
+
+  # Get a job tied to a given SchedulerAgent
+  def scheduler_agent_job(agent)
+    scheduler_agent_jobs.find { |job|
+      job.scheduler_agent_id == agent.id
+    }
+  end
+
+  # Schedule or reschedule a job for a given SchedulerAgent and return
+  # the running job.  Return nil if unscheduled.
+  def schedule_scheduler_agent(agent)
+    job = scheduler_agent_job(agent)
+
+    if agent.disabled?
+      if job
+        puts "Unscheduling SchedulerAgent##{agent.id} (disabled)"
+        job.unschedule
+      end
+      nil
+    else
+      if job
+        return job if agent.memory['scheduled_at'] == job.scheduled_at.to_i
+        puts "Rescheduling SchedulerAgent##{agent.id}"
+        job.unschedule
+      else
+        puts "Scheduling SchedulerAgent##{agent.id}"
+      end
+
+      agent_id = agent.id
+
+      job = schedule_cron agent.options['schedule'], tag: SCHEDULER_AGENT_TAG do |job|
+        job.scheduler_agent_id = agent_id
+
+        if scheduler_agent = job.scheduler_agent
+          scheduler_agent.check!
+        else
+          puts "Unscheduling SchedulerAgent##{job.scheduler_agent_id} (disabled or deleted)"
+          job.unschedule
+        end
+      end
+      # Make sure the job is associated with a SchedulerAgent before
+      # it is triggered.
+      job.scheduler_agent_id = agent_id
+
+      agent.memory['scheduled_at'] = job.scheduled_at.to_i
+      agent.save
+
+      job
+    end
+  end
+
+  # Schedule or reschedule jobs for all SchedulerAgents and unschedule
+  # orphaned jobs if any.
+  def schedule_scheduler_agents
+    scheduled_jobs = Agent.of_type(Agents::SchedulerAgent).map { |scheduler_agent|
+      schedule_scheduler_agent(scheduler_agent)
+    }.compact
+
+    (scheduler_agent_jobs - scheduled_jobs).each { |job|
+      puts "Unscheduling SchedulerAgent##{job.scheduler_agent_id} (orphaned)"
+      job.unschedule
+    }
+  end
+end
+
 class HuginnScheduler
   FAILED_JOBS_TO_KEEP = 100
   attr_accessor :mutex
@@ -45,6 +137,12 @@ class HuginnScheduler
       end
     end
 
+    # Schedule Scheduler Agents
+
+    @rufus_scheduler.every '1m' do
+      @rufus_scheduler.schedule_scheduler_agents
+    end
+
     @rufus_scheduler.join
   end
 

+ 2 - 0
spec/helpers/dot_helper_spec.rb

@@ -58,6 +58,7 @@ describe DotHelper do
           \A
           digraph \x20 "Agent \x20 Event \x20 Flow" \{
             node \[ [^\]]+ \];
+            edge \[ [^\]]+ \];
             (?<foo>\w+) \[label=foo\];
             \k<foo> -> (?<bar1>\w+) \[style=dashed\];
             \k<foo> -> (?<bar2>\w+) \[color="\#999999"\];
@@ -75,6 +76,7 @@ describe DotHelper do
           \A
           digraph \x20 "Agent \x20 Event \x20 Flow" \{
             node \[ [^\]]+ \];
+            edge \[ [^\]]+ \];
             (?<foo>\w+) \[label=foo,tooltip="Dot \x20 Foo",URL="#{Regexp.quote(agent_path(@foo))}"\];
             \k<foo> -> (?<bar1>\w+) \[style=dashed\];
             \k<foo> -> (?<bar2>\w+) \[color="\#999999"\];

+ 57 - 1
spec/lib/huginn_scheduler_spec.rb

@@ -1,4 +1,5 @@
 require 'spec_helper'
+require 'huginn_scheduler'
 
 describe HuginnScheduler do
   before(:each) do
@@ -74,4 +75,59 @@ describe HuginnScheduler do
       ENV['FAILED_JOBS_TO_KEEP'] = old
     end
   end
-end
+end
+
+describe Rufus::Scheduler do
+  before :each do
+    @taoe, Thread.abort_on_exception = Thread.abort_on_exception, false
+    @oso, @ose, $stdout, $stderr = $stdout, $stderr, StringIO.new, StringIO.new
+
+    @scheduler = Rufus::Scheduler.new
+
+    stub.any_instance_of(Agents::SchedulerAgent).second_precision_enabled { true }
+
+    @agent1 = Agents::SchedulerAgent.new(name: 'Scheduler 1', options: { schedule: '*/1 * * * * *' }).tap { |a|
+      a.user = users(:bob)
+      a.save!
+    }
+    @agent2 = Agents::SchedulerAgent.new(name: 'Scheduler 2', options: { schedule: '*/1 * * * * *' }).tap { |a|
+      a.user = users(:bob)
+      a.save!
+    }
+  end
+
+  after :each do
+    @scheduler.shutdown
+
+    Thread.abort_on_exception = @taoe
+    $stdout, $stderr = @oso, @ose
+  end
+
+  describe '#schedule_scheduler_agents' do
+    it 'registers active SchedulerAgents' do
+      @scheduler.schedule_scheduler_agents
+
+      expect(@scheduler.scheduler_agent_jobs.map(&:scheduler_agent)).to eq([@agent1, @agent2])
+    end
+
+    it 'unregisters disabled SchedulerAgents' do
+      @scheduler.schedule_scheduler_agents
+
+      @agent1.update!(disabled: true)
+
+      @scheduler.schedule_scheduler_agents
+
+      expect(@scheduler.scheduler_agent_jobs.map(&:scheduler_agent)).to eq([@agent2])
+    end
+
+    it 'unregisters deleted SchedulerAgents' do
+      @scheduler.schedule_scheduler_agents
+
+      @agent2.delete
+
+      @scheduler.schedule_scheduler_agents
+
+      expect(@scheduler.scheduler_agent_jobs.map(&:scheduler_agent)).to eq([@agent1])
+    end
+  end
+end

+ 23 - 1
spec/models/agent_spec.rb

@@ -486,7 +486,7 @@ describe Agent do
         agent.errors_on(:options).should include("cannot be set to an instance of Fixnum")
       end
 
-      it "should not allow agents owned by other people" do
+      it "should not allow source agents owned by other people" do
         agent = Agents::SomethingSource.new(:name => "something")
         agent.user = users(:bob)
         agent.source_ids = [agents(:bob_weather_agent).id]
@@ -497,6 +497,28 @@ describe Agent do
         agent.should have(0).errors_on(:sources)
       end
 
+      it "should not allow controller agents owned by other people" do
+        agent = Agents::SomethingSource.new(:name => "something")
+        agent.user = users(:bob)
+        agent.controller_ids = [agents(:bob_weather_agent).id]
+        agent.should have(0).errors_on(:controllers)
+        agent.controller_ids = [agents(:jane_weather_agent).id]
+        agent.should have(1).errors_on(:controllers)
+        agent.user = users(:jane)
+        agent.should have(0).errors_on(:controllers)
+      end
+
+      it "should not allow control target agents owned by other people" do
+        agent = Agents::CannotBeScheduled.new(:name => "something")
+        agent.user = users(:bob)
+        agent.control_target_ids = [agents(:bob_weather_agent).id]
+        agent.should have(0).errors_on(:control_targets)
+        agent.control_target_ids = [agents(:jane_weather_agent).id]
+        agent.should have(1).errors_on(:control_targets)
+        agent.user = users(:jane)
+        agent.should have(0).errors_on(:control_targets)
+      end
+
       it "should not allow scenarios owned by other people" do
         agent = Agents::SomethingSource.new(:name => "something")
         agent.user = users(:bob)

+ 140 - 0
spec/models/agents/scheduler_agent_spec.rb

@@ -0,0 +1,140 @@
+require 'spec_helper'
+
+describe Agents::SchedulerAgent do
+  before do
+    @agent = Agents::SchedulerAgent.new(name: 'Example', options: { 'schedule' => '0 * * * *' })
+    @agent.user = users(:bob)
+    @agent.save
+  end
+
+  describe "validation" do
+    it "should validate action" do
+      ['run', 'enable', 'disable', '', nil].each { |action|
+        @agent.options['action'] = action
+        @agent.should be_valid
+      }
+
+      ['delete', 1, true].each { |action|
+        @agent.options['action'] = action
+        @agent.should_not be_valid
+      }
+    end
+
+    it "should validate schedule" do
+      @agent.should be_valid
+
+      @agent.options.delete('schedule')
+      @agent.should_not be_valid
+
+      @agent.options['schedule'] = nil
+      @agent.should_not be_valid
+
+      @agent.options['schedule'] = ''
+      @agent.should_not be_valid
+
+      @agent.options['schedule'] = '0'
+      @agent.should_not be_valid
+
+      @agent.options['schedule'] = '*/15 * * * * * *'
+      @agent.should_not be_valid
+
+      @agent.options['schedule'] = '*/1 * * * *'
+      @agent.should be_valid
+
+      @agent.options['schedule'] = '*/1 * * *'
+      @agent.should_not be_valid
+
+      stub(@agent).second_precision_enabled { true }
+      @agent.options['schedule'] = '*/15 * * * * *'
+      @agent.should be_valid
+
+      stub(@agent).second_precision_enabled { false }
+      @agent.options['schedule'] = '*/10 * * * * *'
+      @agent.should_not be_valid
+
+      @agent.options['schedule'] = '5/30 * * * * *'
+      @agent.should_not be_valid
+
+      @agent.options['schedule'] = '*/15 * * * * *'
+      @agent.should be_valid
+
+      @agent.options['schedule'] = '15,45 * * * * *'
+      @agent.should be_valid
+
+      @agent.options['schedule'] = '0 * * * * *'
+      @agent.should be_valid
+    end
+  end
+
+  describe 'control_action' do
+    it "should be one of the supported values" do
+      ['run', '', nil].each { |action|
+        @agent.options['action'] = action
+        @agent.control_action.should == 'run'
+      }
+
+      ['enable', 'disable'].each { |action|
+        @agent.options['action'] = action
+        @agent.control_action.should == action
+      }
+    end
+
+    it "cannot be 'run' if any of the control targets cannot be scheduled" do
+      @agent.control_action.should == 'run'
+      @agent.control_targets = [agents(:bob_rain_notifier_agent)]
+      @agent.should_not be_valid
+    end
+
+    it "can be 'enable' or 'disable' no matter if control targets can be scheduled or not" do
+      ['enable', 'disable'].each { |action|
+        @agent.options['action'] = action
+        @agent.control_targets = [agents(:bob_rain_notifier_agent)]
+        @agent.should be_valid
+      }
+    end
+  end
+
+  describe "save" do
+    it "should delete memory['scheduled_at'] if and only if options is changed" do
+      time = Time.now.to_i
+
+      @agent.memory['scheduled_at'] = time
+      @agent.save
+      @agent.memory['scheduled_at'].should == time
+
+      @agent.memory['scheduled_at'] = time
+      # Currently @agent.options[]= is not detected
+      @agent.options = { 'schedule' => '*/5 * * * *' }
+      @agent.save
+      @agent.memory['scheduled_at'].should be_nil
+    end
+  end
+
+  describe "check!" do
+    it "should control targets" do
+      control_targets = [agents(:bob_website_agent), agents(:bob_weather_agent)]
+      @agent.control_targets = control_targets
+      @agent.save!
+
+      control_target_ids = control_targets.map(&:id)
+      stub(Agent).async_check(anything) { |id|
+        control_target_ids.delete(id)
+      }
+
+      @agent.check!
+      control_target_ids.should be_empty
+
+      @agent.options['action'] = 'disable'
+      @agent.save!
+
+      @agent.check!
+      control_targets.all? { |control_target| control_target.disabled? }
+
+      @agent.options['action'] = 'enable'
+      @agent.save!
+
+      @agent.check!
+      control_targets.all? { |control_target| !control_target.disabled? }
+    end
+  end
+end