the shed // ruby devops // windows automation

schtasks.exe flag soup makes it too easy to silently recreate a task every single deploy. Here’s an idempotent Ruby wrapper around the real Task Scheduler COM API — tested with a fixture harness even off Windows.




scheduled_task_manager.rb

schtasks /create /tr ... /sc ... /st ... works, but the flag syntax is easy to get subtly wrong — quoting an argument list, trigger syntax, run-level flags — and it gives you no clean way to check “does this task already exist with the settings I want” before deciding whether to touch it. Most schtasks-based deploy scripts cope by just blindly recreating the task every run.

scheduled_task_manager.rb talks to the exact same Task Scheduler service through its native COM object model (the same one the Task Scheduler GUI and schtasks.exe both sit on top of), wraps it in a small idempotent apply(), and reports back whether it created, updated, or left a task alone.

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# scheduled_task_manager.rb -- Declarative Windows Scheduled Task management
# via the Task Scheduler 2.0 COM API (WIN32OLE), instead of shelling out to
# schtasks.exe.
#
# schtasks.exe works, but its /create /tr /sc /st flag soup is easy to get
# subtly wrong (quoting, trigger syntax, run-level flags), and it gives you
# no clean way to inspect an existing task's settings before deciding
# whether to touch it. This script talks to the same Task Scheduler service
# schtasks.exe talks to, but through its COM object model, so it can create,
# update-in-place (idempotently), list, and remove tasks from a small Ruby
# API and a one-line CLI.
#
# Usage (run elevated on Windows; Ruby installed via RubyInstaller):
#   ruby scheduled_task_manager.rb list
#   ruby scheduled_task_manager.rb apply --name "NightlyBackup" \
#       --command "C:\Ruby33\bin\ruby.exe" --args "C:\ops\backup.rb" \
#       --schedule daily --at 02:30 --run-as SYSTEM
#   ruby scheduled_task_manager.rb remove --name "NightlyBackup"
#
# This file is organized so the Task Scheduler logic lives in a class
# (TaskSchedulerClient) that takes an injectable "engine" object -- in
# production that's WIN32OLE talking to the real "Schedule.Service", but the
# test suite (scheduled_task_manager_test.rb) swaps in a plain Ruby fake that
# mimics the handful of COM calls this script makes. That's what lets the
# core create/update/diff logic be verified on a machine with no Task
# Scheduler at all.
require 'optparse'
require 'time'
# ---------------------------------------------------------------------------
# Real WIN32OLE-backed engine. Only require'd (and only instantiated) on an
# actual Windows host -- see main() at the bottom.
# ---------------------------------------------------------------------------
class RealTaskEngine
  FOLDER = '\\'
  TASK_CREATE_OR_UPDATE = 6
  TASK_LOGON_SERVICE_ACCOUNT = 5
  TASK_LOGON_PASSWORD = 1
  TASK_ACTION_EXEC = 0
  TASK_TRIGGER_DAILY = 2
  TASK_TRIGGER_WEEKLY = 3
  def initialize
    require 'win32ole'
    @service = WIN32OLE.new('Schedule.Service')
    @service.Connect
    @root_folder = @service.GetFolder(FOLDER)
  end
  def list
    tasks = []
    @root_folder.GetTasks(0).each do |t|
      tasks << { name: t.Name, state: t.State, next_run: safe_next_run(t), enabled: t.Enabled }
    end
    tasks
  end
  def find(name)
    @root_folder.GetTask(name)
  rescue WIN32OLERuntimeError
    nil
  end
  def create_or_update(spec)
    task_def = @service.NewTask(0)
    task_def.RegistrationInfo.Description = spec[:description] || "Managed by scheduled_task_manager.rb"
    task_def.Settings.Enabled = true
    task_def.Settings.StartWhenAvailable = true
    trigger = build_trigger(task_def, spec)
    trigger.StartBoundary = spec[:start_boundary]
    trigger.Enabled = true
    action = task_def.Actions.Create(TASK_ACTION_EXEC)
    action.Path = spec[:command]
    action.Arguments = spec[:args].to_s
    logon_type = spec[:run_as] == 'SYSTEM' ? TASK_LOGON_SERVICE_ACCOUNT : TASK_LOGON_PASSWORD
    user_id = spec[:run_as] == 'SYSTEM' ? 'SYSTEM' : spec[:run_as]
    @root_folder.RegisterTaskDefinition(
      spec[:name], task_def, TASK_CREATE_OR_UPDATE, user_id, spec[:password], logon_type
    )
  end
  def remove(name)
    @root_folder.DeleteTask(name, 0)
  end
  private
  def build_trigger(task_def, spec)
    kind = spec[:schedule] == 'weekly' ? TASK_TRIGGER_WEEKLY : TASK_TRIGGER_DAILY
    task_def.Triggers.Create(kind)
  end
  def safe_next_run(t)
    t.NextRunTime
  rescue StandardError
    nil
  end
end
# ---------------------------------------------------------------------------
# Task spec builder + idempotent diff logic. This part is pure Ruby with no
# COM calls at all, which is exactly why it's the part that's unit-tested
# directly (see the test file) rather than only exercised through the fake
# engine.
# ---------------------------------------------------------------------------
class TaskSchedulerClient
  def initialize(engine)
    @engine = engine
  end
  def list
    @engine.list
  end
  # Returns :created, :updated, or :unchanged
  def apply(spec)
    validate!(spec)
    existing = @engine.find(spec[:name])
    if existing.nil?
      @engine.create_or_update(spec)
      return :created
    end
    if same?(existing, spec)
      :unchanged
    else
      @engine.create_or_update(spec)
      :updated
    end
  end
  def remove(name)
    @engine.remove(name)
  end
  private
  def validate!(spec)
    raise ArgumentError, 'name is required' if spec[:name].to_s.empty?
    raise ArgumentError, 'command is required' if spec[:command].to_s.empty?
    raise ArgumentError, 'schedule must be daily or weekly' unless %w[daily weekly].include?(spec[:schedule])
    hh, mm = spec[:at].to_s.split(':')
    raise ArgumentError, 'at must be HH:MM (24h)' unless hh && mm && hh.to_i.between?(0, 23) && mm.to_i.between?(0, 59)
    spec[:start_boundary] ||= build_start_boundary(spec[:at])
  end
  def build_start_boundary(at)
    hh, mm = at.split(':').map(&:to_i)
    today = Time.now
    start = Time.new(today.year, today.month, today.day, hh, mm, 0)
    start.strftime('%Y-%m-%dT%H:%M:%S')
  end
  # A task is considered "the same" (no COM write needed) when the fields we
  # manage all already match. Real Task Scheduler objects expose these as
  # nested COM properties (Definition.Actions/.Triggers); the fake test
  # engine mirrors just enough of that shape to exercise this comparison.
  def same?(existing, spec)
    action = existing.Definition.Actions.Item(1)
    trigger = existing.Definition.Triggers.Item(1)
    # Compare only the HH:MM time-of-day, not the date: StartBoundary on the
    # existing task carries whatever date it was originally created on,
    # while a freshly-built spec always carries today's date, so comparing
    # full timestamps would report "updated" every single day even when the
    # schedule itself never changed.
    action.Path == spec[:command] &&
      action.Arguments.to_s == spec[:args].to_s &&
      time_of_day(trigger.StartBoundary) == time_of_day(spec[:start_boundary])
  rescue StandardError
    false
  end
  def time_of_day(iso_timestamp)
    iso_timestamp.to_s[11, 5] # "YYYY-MM-DDTHH:MM:SS" -> "HH:MM"
  end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv)
  command = argv.shift
  options = { schedule: 'daily', run_as: 'SYSTEM', password: nil }
  OptionParser.new do |o|
    o.banner = 'Usage: scheduled_task_manager.rb <list|apply|remove> [options]'
    o.on('--name NAME', 'Task name') { |v| options[:name] = v }
    o.on('--command PATH', 'Executable to run') { |v| options[:command] = v }
    o.on('--args ARGS', 'Arguments passed to the executable') { |v| options[:args] = v }
    o.on('--schedule TYPE', %w[daily weekly], 'daily or weekly (default daily)') { |v| options[:schedule] = v }
    o.on('--at HH:MM', 'Time of day to run, 24h') { |v| options[:at] = v }
    o.on('--run-as USER', 'SYSTEM or DOMAIN\\user (default SYSTEM)') { |v| options[:run_as] = v }
    o.on('--password PASS', 'Password for --run-as user (not needed for SYSTEM)') { |v| options[:password] = v }
    o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
  end.parse!(argv)
  [command, options]
end
def main
  command, options = parse_args(ARGV)
  unless %w[list apply remove].include?(command)
    warn 'Usage: scheduled_task_manager.rb <list|apply|remove> [options]'
    exit 1
  end
  unless RUBY_PLATFORM.match?(/mingw|mswin/)
    warn "This command talks to Windows Task Scheduler via WIN32OLE and only runs on Windows."
    warn "(Ruby detected platform: #{RUBY_PLATFORM}). See scheduled_task_manager_test.rb for the" \
         " fixture-driven test suite that exercises this script's logic on any OS."
    exit 1
  end
  client = TaskSchedulerClient.new(RealTaskEngine.new)
  case command
  when 'list'
    client.list.each do |t|
      puts format('%-30s state=%-10s enabled=%-5s next_run=%s', t[:name], t[:state], t[:enabled], t[:next_run])
    end
  when 'apply'
    result = client.apply(options)
    puts "Task '#{options[:name]}': #{result}"
  when 'remove'
    client.remove(options[:name])
    puts "Task '#{options[:name]}': removed"
  end
rescue ArgumentError => e
  warn "Error: #{e.message}"
  exit 1
end
main if $PROGRAM_NAME == __FILE__

Two engines behind one interface. RealTaskEngine wraps WIN32OLE.new('Schedule.Service') and does the actual COM calls; it’s only instantiated when RUBY_PLATFORM says Windows. TaskSchedulerClient — the part that actually matters — is completely COM-agnostic: it validates a spec, asks the engine to find an existing task, and decides whether a write is even necessary.

The idempotency check compares time-of-day, not the full date — on purpose. A freshly built spec always carries today’s date, while an existing task’s StartBoundary carries whatever date it was first created on. An early version of same? compared full timestamps and reported :updated on every single run once a day boundary passed, even when the schedule never changed. The test suite caught this immediately (see the output tab) and the fix slices out just the HH:MM portion for comparison.

One COM call handles both create and update. RegisterTaskDefinition is called with TASK_CREATE_OR_UPDATE, so the CLI just needs to know whether a prior task existed to report :created vs :updated.

Testing something that only exists on Windows. FakeTaskEngine (in the test file) is a plain-Ruby double built from Structs shaped just enough like the real COM objects (Definition.Actions.Item(1).Path, etc.) to exercise every branch of apply() — create, idempotent no-op, update-on-drift, validation errors — with zero dependency on an actual Windows host.

> ruby scheduled_task_manager.rb apply --name NightlyBackup --command C:\Ruby33\bin\ruby.exe ^
    --args C:\ops\backup.rb --schedule daily --at 02:30 --run-as SYSTEM
Task 'NightlyBackup': created
> ruby scheduled_task_manager.rb apply --name NightlyBackup --command C:\Ruby33\bin\ruby.exe ^
    --args C:\ops\backup.rb --schedule daily --at 02:30 --run-as SYSTEM
Task 'NightlyBackup': unchanged
> ruby scheduled_task_manager.rb apply --name NightlyBackup --command C:\Ruby33\bin\ruby.exe ^
    --args C:\ops\backup.rb --schedule daily --at 04:00 --run-as SYSTEM
Task 'NightlyBackup': updated
$ ruby scheduled_task_manager_test.rb
Run options: --seed 54714
# Running:
.........
Finished in 0.001146s, 7851.4506 runs/s, 13958.1343 assertions/s.
9 runs, 16 assertions, 0 failures, 0 errors, 0 skips

Get the code

Full script + README on GitHub: ruby-devops-toolkit/scheduled-task-manager

scheduled_task_manager.rb architecture: CLI, idempotent diff, and the real/fake engine split

TaskSchedulerClient validates and diffs; RealTaskEngine (WIN32OLE) and FakeTaskEngine (test fixtures) share one interface
Step 1

Prerequisites

  • Windows, with Ruby installed via RubyInstaller (WIN32OLE ships with RubyInstaller’s builds).
  • An elevated (Administrator) shell for creating tasks that run as SYSTEM or another user.
  • No gems required — win32ole is part of the Ruby standard library on Windows builds.
Step 2

Full script for reference

scheduled_task_manager.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# scheduled_task_manager.rb -- Declarative Windows Scheduled Task management
# via the Task Scheduler 2.0 COM API (WIN32OLE), instead of shelling out to
# schtasks.exe.
#
# schtasks.exe works, but its /create /tr /sc /st flag soup is easy to get
# subtly wrong (quoting, trigger syntax, run-level flags), and it gives you
# no clean way to inspect an existing task's settings before deciding
# whether to touch it. This script talks to the same Task Scheduler service
# schtasks.exe talks to, but through its COM object model, so it can create,
# update-in-place (idempotently), list, and remove tasks from a small Ruby
# API and a one-line CLI.
#
# Usage (run elevated on Windows; Ruby installed via RubyInstaller):
#   ruby scheduled_task_manager.rb list
#   ruby scheduled_task_manager.rb apply --name "NightlyBackup" \
#       --command "C:\Ruby33\bin\ruby.exe" --args "C:\ops\backup.rb" \
#       --schedule daily --at 02:30 --run-as SYSTEM
#   ruby scheduled_task_manager.rb remove --name "NightlyBackup"
#
# This file is organized so the Task Scheduler logic lives in a class
# (TaskSchedulerClient) that takes an injectable "engine" object -- in
# production that's WIN32OLE talking to the real "Schedule.Service", but the
# test suite (scheduled_task_manager_test.rb) swaps in a plain Ruby fake that
# mimics the handful of COM calls this script makes. That's what lets the
# core create/update/diff logic be verified on a machine with no Task
# Scheduler at all.
require 'optparse'
require 'time'
# ---------------------------------------------------------------------------
# Real WIN32OLE-backed engine. Only require'd (and only instantiated) on an
# actual Windows host -- see main() at the bottom.
# ---------------------------------------------------------------------------
class RealTaskEngine
  FOLDER = '\\'
  TASK_CREATE_OR_UPDATE = 6
  TASK_LOGON_SERVICE_ACCOUNT = 5
  TASK_LOGON_PASSWORD = 1
  TASK_ACTION_EXEC = 0
  TASK_TRIGGER_DAILY = 2
  TASK_TRIGGER_WEEKLY = 3
  def initialize
    require 'win32ole'
    @service = WIN32OLE.new('Schedule.Service')
    @service.Connect
    @root_folder = @service.GetFolder(FOLDER)
  end
  def list
    tasks = []
    @root_folder.GetTasks(0).each do |t|
      tasks << { name: t.Name, state: t.State, next_run: safe_next_run(t), enabled: t.Enabled }
    end
    tasks
  end
  def find(name)
    @root_folder.GetTask(name)
  rescue WIN32OLERuntimeError
    nil
  end
  def create_or_update(spec)
    task_def = @service.NewTask(0)
    task_def.RegistrationInfo.Description = spec[:description] || "Managed by scheduled_task_manager.rb"
    task_def.Settings.Enabled = true
    task_def.Settings.StartWhenAvailable = true
    trigger = build_trigger(task_def, spec)
    trigger.StartBoundary = spec[:start_boundary]
    trigger.Enabled = true
    action = task_def.Actions.Create(TASK_ACTION_EXEC)
    action.Path = spec[:command]
    action.Arguments = spec[:args].to_s
    logon_type = spec[:run_as] == 'SYSTEM' ? TASK_LOGON_SERVICE_ACCOUNT : TASK_LOGON_PASSWORD
    user_id = spec[:run_as] == 'SYSTEM' ? 'SYSTEM' : spec[:run_as]
    @root_folder.RegisterTaskDefinition(
      spec[:name], task_def, TASK_CREATE_OR_UPDATE, user_id, spec[:password], logon_type
    )
  end
  def remove(name)
    @root_folder.DeleteTask(name, 0)
  end
  private
  def build_trigger(task_def, spec)
    kind = spec[:schedule] == 'weekly' ? TASK_TRIGGER_WEEKLY : TASK_TRIGGER_DAILY
    task_def.Triggers.Create(kind)
  end
  def safe_next_run(t)
    t.NextRunTime
  rescue StandardError
    nil
  end
end
# ---------------------------------------------------------------------------
# Task spec builder + idempotent diff logic. This part is pure Ruby with no
# COM calls at all, which is exactly why it's the part that's unit-tested
# directly (see the test file) rather than only exercised through the fake
# engine.
# ---------------------------------------------------------------------------
class TaskSchedulerClient
  def initialize(engine)
    @engine = engine
  end
  def list
    @engine.list
  end
  # Returns :created, :updated, or :unchanged
  def apply(spec)
    validate!(spec)
    existing = @engine.find(spec[:name])
    if existing.nil?
      @engine.create_or_update(spec)
      return :created
    end
    if same?(existing, spec)
      :unchanged
    else
      @engine.create_or_update(spec)
      :updated
    end
  end
  def remove(name)
    @engine.remove(name)
  end
  private
  def validate!(spec)
    raise ArgumentError, 'name is required' if spec[:name].to_s.empty?
    raise ArgumentError, 'command is required' if spec[:command].to_s.empty?
    raise ArgumentError, 'schedule must be daily or weekly' unless %w[daily weekly].include?(spec[:schedule])
    hh, mm = spec[:at].to_s.split(':')
    raise ArgumentError, 'at must be HH:MM (24h)' unless hh && mm && hh.to_i.between?(0, 23) && mm.to_i.between?(0, 59)
    spec[:start_boundary] ||= build_start_boundary(spec[:at])
  end
  def build_start_boundary(at)
    hh, mm = at.split(':').map(&:to_i)
    today = Time.now
    start = Time.new(today.year, today.month, today.day, hh, mm, 0)
    start.strftime('%Y-%m-%dT%H:%M:%S')
  end
  # A task is considered "the same" (no COM write needed) when the fields we
  # manage all already match. Real Task Scheduler objects expose these as
  # nested COM properties (Definition.Actions/.Triggers); the fake test
  # engine mirrors just enough of that shape to exercise this comparison.
  def same?(existing, spec)
    action = existing.Definition.Actions.Item(1)
    trigger = existing.Definition.Triggers.Item(1)
    # Compare only the HH:MM time-of-day, not the date: StartBoundary on the
    # existing task carries whatever date it was originally created on,
    # while a freshly-built spec always carries today's date, so comparing
    # full timestamps would report "updated" every single day even when the
    # schedule itself never changed.
    action.Path == spec[:command] &&
      action.Arguments.to_s == spec[:args].to_s &&
      time_of_day(trigger.StartBoundary) == time_of_day(spec[:start_boundary])
  rescue StandardError
    false
  end
  def time_of_day(iso_timestamp)
    iso_timestamp.to_s[11, 5] # "YYYY-MM-DDTHH:MM:SS" -> "HH:MM"
  end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv)
  command = argv.shift
  options = { schedule: 'daily', run_as: 'SYSTEM', password: nil }
  OptionParser.new do |o|
    o.banner = 'Usage: scheduled_task_manager.rb <list|apply|remove> [options]'
    o.on('--name NAME', 'Task name') { |v| options[:name] = v }
    o.on('--command PATH', 'Executable to run') { |v| options[:command] = v }
    o.on('--args ARGS', 'Arguments passed to the executable') { |v| options[:args] = v }
    o.on('--schedule TYPE', %w[daily weekly], 'daily or weekly (default daily)') { |v| options[:schedule] = v }
    o.on('--at HH:MM', 'Time of day to run, 24h') { |v| options[:at] = v }
    o.on('--run-as USER', 'SYSTEM or DOMAIN\\user (default SYSTEM)') { |v| options[:run_as] = v }
    o.on('--password PASS', 'Password for --run-as user (not needed for SYSTEM)') { |v| options[:password] = v }
    o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
  end.parse!(argv)
  [command, options]
end
def main
  command, options = parse_args(ARGV)
  unless %w[list apply remove].include?(command)
    warn 'Usage: scheduled_task_manager.rb <list|apply|remove> [options]'
    exit 1
  end
  unless RUBY_PLATFORM.match?(/mingw|mswin/)
    warn "This command talks to Windows Task Scheduler via WIN32OLE and only runs on Windows."
    warn "(Ruby detected platform: #{RUBY_PLATFORM}). See scheduled_task_manager_test.rb for the" \
         " fixture-driven test suite that exercises this script's logic on any OS."
    exit 1
  end
  client = TaskSchedulerClient.new(RealTaskEngine.new)
  case command
  when 'list'
    client.list.each do |t|
      puts format('%-30s state=%-10s enabled=%-5s next_run=%s', t[:name], t[:state], t[:enabled], t[:next_run])
    end
  when 'apply'
    result = client.apply(options)
    puts "Task '#{options[:name]}': #{result}"
  when 'remove'
    client.remove(options[:name])
    puts "Task '#{options[:name]}': removed"
  end
rescue ArgumentError => e
  warn "Error: #{e.message}"
  exit 1
end
main if $PROGRAM_NAME == __FILE__
Step 3

Step-by-step: what happens on apply

Execution order
  • 1. Validate the spec — name, command, schedule type, and an HH:MM time all required.
  • 2. Look up any existing task by name via the engine’s find.
  • 3. If none exists, call create_or_update and report :created.
  • 4. If one exists, compare command, arguments, and time-of-day with same?.
  • 5. Report :unchanged with no COM write, or call create_or_update again and report :updated.
Step 4

The fixture-driven test suite

scheduled_task_manager_test.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# scheduled_task_manager_test.rb -- fixture-driven test suite for
# scheduled_task_manager.rb's TaskSchedulerClient, using a fake in-memory
# engine that mimics the handful of WIN32OLE/Task Scheduler COM calls the
# real RealTaskEngine makes. This lets the create/update/diff logic be
# verified on any OS, with no real Windows host or Task Scheduler service
# involved -- see the README for exactly what this does and does not prove.
#
# Run with: ruby scheduled_task_manager_test.rb
require_relative 'scheduled_task_manager'
require 'minitest/autorun'
# ---------------------------------------------------------------------------
# Fake COM object shapes, just deep enough to satisfy TaskSchedulerClient's
# #same? comparison (existing.Definition.Actions.Item(1).Path, etc.) and
# RealTaskEngine's #create_or_update / #remove call shape.
# ---------------------------------------------------------------------------
FakeAction = Struct.new(:Path, :Arguments)
FakeTrigger = Struct.new(:StartBoundary)
FakeActions = Struct.new(:list) do
  def Item(i) = list[i - 1]
end
FakeTriggers = Struct.new(:list) do
  def Item(i) = list[i - 1]
end
FakeDefinition = Struct.new(:Actions, :Triggers)
FakeTask = Struct.new(:Name, :Definition, :State, :Enabled)
class FakeTaskEngine
  attr_reader :registered # spec hashes passed to create_or_update, in order
  attr_reader :removed    # task names passed to remove, in order
  def initialize(seed_tasks = {})
    @tasks = seed_tasks # name => FakeTask
    @registered = []
    @removed = []
  end
  def list
    @tasks.values.map { |t| { name: t.Name, state: t.State, next_run: nil, enabled: t.Enabled } }
  end
  def find(name)
    @tasks[name]
  end
  def create_or_update(spec)
    @registered << spec
    @tasks[spec[:name]] = FakeTask.new(
      spec[:name],
      FakeDefinition.new(
        FakeActions.new([FakeAction.new(spec[:command], spec[:args].to_s)]),
        FakeTriggers.new([FakeTrigger.new(spec[:start_boundary])])
      ),
      'Ready',
      true
    )
  end
  def remove(name)
    @removed << name
    @tasks.delete(name)
  end
end
class TaskSchedulerClientTest < Minitest::Test
  def valid_spec(overrides = {})
    {
      name: 'NightlyBackup',
      command: 'C:\\Ruby33\\bin\\ruby.exe',
      args: 'C:\\ops\\backup.rb',
      schedule: 'daily',
      at: '02:30',
      run_as: 'SYSTEM'
    }.merge(overrides)
  end
  def test_apply_creates_new_task
    engine = FakeTaskEngine.new
    client = TaskSchedulerClient.new(engine)
    result = client.apply(valid_spec)
    assert_equal :created, result
    assert_equal 1, engine.registered.length
    assert_equal 'NightlyBackup', engine.registered.first[:name]
  end
  def test_apply_is_idempotent_when_nothing_changed
    engine = FakeTaskEngine.new
    client = TaskSchedulerClient.new(engine)
    first = client.apply(valid_spec)
    second = client.apply(valid_spec)
    assert_equal :created, first
    assert_equal :unchanged, second
    assert_equal 1, engine.registered.length, 'a second identical apply should not re-register the task'
  end
  def test_apply_updates_when_command_changes
    engine = FakeTaskEngine.new
    client = TaskSchedulerClient.new(engine)
    client.apply(valid_spec)
    result = client.apply(valid_spec(command: 'C:\\Ruby33\\bin\\ruby.exe', args: 'C:\\ops\\backup_v2.rb'))
    assert_equal :updated, result
    assert_equal 2, engine.registered.length
  end
  def test_apply_updates_when_schedule_time_changes
    engine = FakeTaskEngine.new
    client = TaskSchedulerClient.new(engine)
    client.apply(valid_spec(at: '02:30'))
    result = client.apply(valid_spec(at: '04:00'))
    assert_equal :updated, result
  end
  def test_apply_rejects_missing_name
    client = TaskSchedulerClient.new(FakeTaskEngine.new)
    assert_raises(ArgumentError) { client.apply(valid_spec(name: '')) }
  end
  def test_apply_rejects_bad_time_format
    client = TaskSchedulerClient.new(FakeTaskEngine.new)
    assert_raises(ArgumentError) { client.apply(valid_spec(at: '25:99')) }
  end
  def test_apply_rejects_invalid_schedule
    client = TaskSchedulerClient.new(FakeTaskEngine.new)
    assert_raises(ArgumentError) { client.apply(valid_spec(schedule: 'monthly')) }
  end
  def test_remove_delegates_to_engine
    engine = FakeTaskEngine.new
    client = TaskSchedulerClient.new(engine)
    client.apply(valid_spec)
    client.remove('NightlyBackup')
    assert_equal ['NightlyBackup'], engine.removed
    assert_nil engine.find('NightlyBackup')
  end
  def test_list_reflects_engine_state
    seed = { 'Existing' => FakeTask.new('Existing', nil, 'Ready', true) }
    engine = FakeTaskEngine.new(seed)
    client = TaskSchedulerClient.new(engine)
    tasks = client.list
    assert_equal 1, tasks.length
    assert_equal 'Existing', tasks.first[:name]
  end
end
Step 5

Troubleshooting

Running this on Linux/macOS exits immediately

That's intentional — WIN32OLE and Task Scheduler don't exist off Windows. Use the fixture-driven test suite to exercise the logic anywhere.
RegisterTaskDefinition raises Access is denied

The shell isn't elevated, or –run-as names a user without "Log on as a batch job" rights — grant it via secpol.msc.
apply always reports :updated even with no real change

Make sure same? compares the HH:MM time portion of StartBoundary, not the date — an earlier draft compared dates and failed exactly this way (see the walkthrough tab).
Step 6

Extending it

  • Add support for TASK_TRIGGER_LOGON / TASK_TRIGGER_BOOT triggers.
  • Add a --dry-run that reports the created/updated/unchanged verdict without calling RegisterTaskDefinition.
  • Extend same? to diff Settings (execution time limits, StartWhenAvailable) too, not just actions/triggers.