almostearthling / whenever

The Whenever task scheduler

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

The Whenever Task Scheduler

whenever is a simple task scheduler capable of executing tasks (OS commands and Lua scripts) according to specific conditions. Conditions are of various types: depending on time (both intervals or specific more-or-less defined instants), execution of OS commands or Lua scripts, changes in specific files and directories, session inactivity, DBus signals or property checks.1 The scheduler intends to be as lightweight as possible in terms of used computational resources, and to run at a low priority level.

Configuration is provided to the scheduler via a TOML file, which must contain all definitions for conditions and associated tasks, as well as events that the scheduler should listen to.

Ideally, whenever is the successor of the Python based When scheduler, with the intention of being cross platform, more efficient and as least resource-consuming as possible. It also gained some features (eg. Lua scripting) that When did not have, at no cost in terms of performance since whenever is a self-contained, optimized, and thin executable instead of being an interpreted program.

Although a command line application, it is designed for desktops -- therefore it should be executed via a controlling GUI wrapper.

Introduction

The purpose of whenever is to provide the user, possibly without administrative credentials, with the ability to define conditions that do not only depend on time, but also on particular states of the session, result of commands run in a shell, execution of Lua scripts, or other events that may occur while the system is being used. This scheduler is a terminal (or console, on Windows) application, however it is meant to run in the background without interaction with the user. The application is able to produce detailed logs, so that the user can review what the application is doing or has done.

Just like its predecessor, whenever overlaps to some extent with the standard cron scheduler on Unix, and with the Task Scheduler on Windows. However this scheduler tries to be more flexible -- although less precise than cron -- and to function as an alternative to more complex solutions that could be implemented using the system-provided schedulers. The whenever approach is to perform certain tasks after a condition is met, in a relaxed fashion: this means that the tasks might not be performed exactly in the instant that marks the condition verification, but after such verification instead. Thus this scheduler is not intended as a replacement for the utilities provided by the operating system: it aims at representing an easy solution for those who need to automate some actions depending on other situations or events that may occur.

Also, whenever aims at being cross-platform: until now, all features are available on all supported operating systems -- although in some cases part of these features (DBus support, for example) can be of little or no use on some supported environments. In opposition to its predecessor, whenever tries to be conservative in terms of resource cosumption (especially CPU and RAM), and, since it does not interact with the user normally, it should be able to run at low priority. Therefore, whenever does not implement a GUI by itself: on the contrary, it offers a simple stdin-based interface that is mostly aimed at interacting with an independent wrapper. Also, no persistence is implemented in this version. The actions to be performed are loaded every time at startup by means of a single configuration file that, as many modern tools do, uses the well known TOML format.2

A very lightweight cross-platform wrapper, namely whenever_tray, is available and under active testing on both Linux and Windows. It is developed in C++ and uses the WxWidgets GUI library: it has been designed to implement the bare minimum of functionality and to just show an icon in the system tray area, from which it is possible to stop the scheduler, and to pause/resume the condition checks and therefore the execution of tasks that would derive from them. The minimalistic wrapper also hides the console window on Windows environments. Due to the use of stdin/stdout for communication, it is possible to build more elaborate wrappers in any language that supports the possibility to spawn a process and control its I/O, at the expense of a larger occupation of the resources but possibly without drawbacks in terms of performance, as the scheduler runs in a separate task anyway. The Python based When application had an occupation in RAM of about 70MB on Ubuntu Linux using a not-too-populated configuration file, and could noticeably use the CPU: this version, written in the Rust programming language, needs around 1.5MB of RAM on Windows3 when using a configuration file that tests all possible types of task, condition, and event supported on the platform. Nevertheless, whenever is fully multithreaded, condition checks have no influence on each other and, when needed, may run concurrently. Consequential task execution also takes place with full parallelism -- with the exception of tasks that, per configuration, must run sequentially.

Features

whenever can perform the following types of task:

  • Execution of groups of OS executables, either sequentially or concurrently, checking their exit code or output (both on stdout and stderr) for expected or undesired results
  • Execution of Lua scripts, using an embedded interpreter, with the possibility of checking the contents of Lua variables for expected outcomes

as the consequence of the verification of a condition. The concepts of tasks and conditions are inherited from the Python based When scheduler: how tasks and conditions work is almost identical in both tools -- in fact, development of a tool to convert from When export files to whenever configuration files is underway.

The supported types of condition are the following:

  • Interval based: the periodic conditions are verified after a certain time interval has passed since whenever has started, and may be verified again after the same amount of time if the condition is set to be recurring
  • Time based: one or more instants in time can be specified when the condition is verified
  • Idle user session: this type of condition is verified after the session has been idle for the specified amount of time
  • Command execution: an available executable (be it a script, a batch file on Windows, a binary) is run, its exit code or output is checked and, when an expected outcome is found, the condition is considered verified - or failed on an explicitly undesired outcome
  • Lua script execution: a Lua script is run using the embedded interpreter, and if the contents of one or more variables meet the specified expectations the condition is considered verified
  • DBus inspection: a DBus method is executed and the result is checked against some criteria provided in the configuration file
  • Event based: are verified when a certain event occurs that fires the condition.

The events that can fire event based conditions are, at the moment:

  • Filesystem changes, that is, changes in files and/or directories that are set to be monitored
  • DBus signals that may be filtered for an expected payload.
  • Command line, that are manually triggered by writing to whenever standard input.

Note that DBus events and conditions are also (theoretically) supported on Windows, being one of the DBus target platforms.

All of the above listed items are fully configurable via a TOML configuration file, that must be specified as the only mandatory argument on the command line. The syntax of the configuration file is described in the following sections.

Every type of check is performed periodically, even the ones involving event based conditions4: the periodic time interval at which the conditions are checked is referred here as tick, and the tick interval can be specified in the configuration file -- defaulting at 5 seconds. Note that, since performing all checks in the same instant at every tick could cause usage peaks in terms of computational resources, there is the option to attempt to randomly distribute some of the checks within the tick interval, by explicitly specifying this behaviour in the configuration file.

CLI

The command can be directly invoked as a foreground process from the command line. This is particularly useful (especially with full logging enabled) to debug the configuration. whenever either logs to the console or to a specified file. When logging to the console, different colors are used by default to visually accentuate messages related to different logging levels.

By invoking whenever and specifying --help as argument, the output is the following:

~$ whenever --help
A simple background job launcher and scheduler

Usage: whenever [OPTIONS] <CONFIG>

Arguments:
  <CONFIG>  Path to configuration file

Options:
  -q, --quiet              Suppress all output (requires logfile to be specified)
  -l, --log <LOGFILE>      Specify the log file
  -L, --log-level <LEVEL>  Specify the log level (default: warn) [default: warn] [possible values: trace, debug, info, warn, error]
  -a, --log-append         Append to an existing log file if found
  -P, --log-plain          No colors when logging (default for log files)
  -C, --log-color          Use colors when logging to console (default, ignored with log files)
  -J, --log-json           Use JSON format for logging
  -h, --help               Print help
  -V, --version            Print version

As the available options are minimal, and mostly impact on logging, the only elements that should be highlighted are the following:

  • whenever requires a log file to run in quiet mode (which also suppresses errors)
  • it is possible to suppress colors when logging to console, by specifying --log-plain as an argument
  • when run within a wrapper, whenever can emit log messages in the JSON format, to make it easier for the wrapper to interpret and classify them.

When debugging a configuration file, it might be useful to set the log level at least to debug, if not to trace which also emits some redundant messages.

An important thing to notice is that configuration errors will cause whenever to abort, by issuing a very brief message on the console.

To exit from whenever (when running as a CLI program from an interactive shell) che usual Ctrl+C key combination can be used. This will however wait for all currently running activities, be it condition checks or tasks, to finish. In order to force whenever to exit abruptly, either a command must be used or it must be explicitly killed.

Configuration

The configuration file is strictly based on the current TOML specification: therefore it can be implemented by hand, or automatically written (for example, by a GUI based utility) using a library capable of writing well-formed TOML files. This section describes the exact format of this file, in all of its components.

Globals

Globals must be specified at the beginning of the configuration file. The supported global entries are the following:

Option Default Description
scheduler_tick_seconds 5 Number of seconds between scheduler ticks
randomize_checks_within_ticks false Whether or not condition checks hould be uniformly randomized within the tick period

Both parameters can be omitted, in which case the default values are used: 5 seconds might seem a very short value for the tick period, but in fact it mimics a certain responsiveness and synchronicity in checking event based conditions. Note that conditions strictly depending on time do not comply to the request of randomizing the check time.

Tasks

Tasks are handled first in this document, because conditions must mandatorily specify the tasks to be executed upon verification. There are two types of task, each of which is described in detail in its specific subsection.

Tasks are defined via a dedicated table, which means that every task definition must start with the TOML [[task]] section header.

Task names are mandatory, and must be provided as alphanumeric strings (may include underscores), beginning with a letter. The task type must be either "command" or "lua" according to what is configured, any other value is considered a configuration error. There is another optional entry, namely tags, that is accepted in item configuration: this entry is ignored by whenever itself, however it is checked for correctness at startup and the configuration is refused if not set to an array (of strings) or a table.

Command tasks

Command based tasks actually execute commands at the OS level: they might have a positive as well as a negative outcome, depending on user-provided criteria. As said above, these criteria may not just depend on the exit code of the executed command, but also on checks performed on its output taking either the standard output or the standard error channels into account. By default no check is performed, but the user can choose, for instance, to consider a zero exit code as a successful execution (quite common for OS commands). It is possible to consider another exit code as successful, or the zero exit code as a failure (for instance, if a file should not be found, performing ls on it would have the zero exit code as an undesirable outcome). Also, a particular substring can be sought in the standard output or standard error streams both as expected or as unexpected. The two streams can be matched against a provided regular expression if just seeking a certain substring is not fine-grained enough. Both substrings and regular expressions can be respectively sought or matched either case-sensitively or case-insensitively.

A sample configuration for a command based task is the following:

[[task]]
name = "CommandTaskName"
type = "command"
startup_path = "/some/startup/directory"    # must exist
command = "executable_name"
command_arguments = [
    "arg1",
    "arg2",
    ]

# optional parameters (if omitted, defaults are used)
match_exact = false
match_regular_expression = false
success_stdout = "expected"
success_stderr = "expected_error"
success_status = 0
failure_stdout = "unexpected"
failure_stderr = "unexpected_error"
failure_status = 2
timeout_seconds = 60
case_sensitive = false
include_environment = false
set_envvironment_variables = false
environment_variables = { VARNAME1 = "value1", VARNAME2 = "value2" }

and the following table provides a detailed description of the entries:

Entry Default Description
name N/A the unique name of the task (mandatory)
type N/A must be set to "command" (mandatory)
startup_path N/A the directory in which the command is started
command N/A path to the executable (mandatory; if the path is omitted, the executable should be found in the search PATH)
command_arguments N/A arguments to pass to the executable: can be an empty list, [] (mandatory)
match_exact false if true, the entire output is matched instead of searching for a substring
match_regular_expression false if true, the match strings are considered regular expressions instead of substrings
case_sensitive false if true, substring search or match and regular expressions match is performed case-sensitively
timeout_seconds (empty) if set, the number of seconds to wait before the command is terminated (with unsuccessful outcome)
success_status (empty) if set, when the execution ends with the provided exit code the task is considered successful
failure_status (empty) if set, when the execution ends with the provided exit code the task is considered failed
success_stdout (empty) the substring or RE to be found or matched on stdout to consider the task successful
success_stderr (empty) the substring or RE to be found or matched on stderr to consider the task successful
failure_stdout (empty) the substring or RE to be found or matched on stdout to consider the task failed
failure_stderr (empty) the substring or RE to be found or matched on stderr to consider the task failed
include_environment true if true, the command is executed in the same environment in which whenever was started
set_environment_variables true if true, whenever sets environment variables reporting the names of the task and the condition
environment_variables {} extra variables that might have to be set in the environment in which the provided command runs

The priority used by whenever to determine success or failure in the task is the one in which the related parameters appear in the above table: first exit codes are checked, then both stdout and stderr are checked for substrings ore regular expressions that identify success, and finally the same check is performed on values that indicate a failure. Most of the times just one or maybe two of the expected parameters will have to be set. Note that the command execution is not considered successful with a zero exit code by default, nor a failure on a nonzero exit code: both assumptions have to be explicitly configured by setting either success_status or failure_status. If a command is known to have the possibility to hang, a timeout can be configured by specifying the maximum number of seconds to wait for the process to exit: after this amount of time the process is terminated and fails.

If set_environment_variables is true, whenever sets the following environment variables:

  • WHENEVER_TASK to the unique name of the task
  • WHENEVER_CONDITION to the unique name of the condition that triggered the task

for scripts or other executables that might be aware of whenever.

Lua script tasks

Tasks based on Lua scripts might be useful when an action has to be performed that requires a non-trivial sequence of operations but for which it would be excessive to write a specific script to be run as a command. The script to be run is embedded directly in the configuration file -- TOML helps in this sense, by allowing multiline strings to be used in its specification.

Lua based tasks can be considered more lightweight than command tasks, as the interpreter is embedded in whenever. Also, the embedded Lua interpreter is enriched with library functions that allow to write to the whenever log, at all logging levels (error, warn, info, debug, trace). The library functions are the following:

  • log.error
  • log.warn
  • log.info
  • log.debug
  • log.trace

and take a single string as their argument.

The configuration of Lua based tasks has the following form:

[[task]]
name = "LuaTaskName"
type = "lua"
script = '''
    log.info("hello from Lua");
    result = 10;
    '''

# optional parameters (if omitted, defaults are used)
expect_all = false
expected_results = { result = 10 }

and the following table provides a detailed description of the entries:

Entry Default Description
name N/A the unique name of the task (mandatory)
type N/A must be set to "lua" (mandatory)
script N/A the Lua code that has to be executed by the internal interpreter (mandatory)
expect_all false if true, all the expected results have to be matched to consider the task successful, otherwise at least one
expected_results {} a dictionary of variable names and their expected values to be checked after execution

Note that triple single quotes have been used to embed the script: this allows to use escapes and quotes in the script itself. Although the script should be embedded in the configuration file, it is possible to execute external scripts via dofile("/path/to/script.lua") or by using the require function. While a successful execution is always determined by matching the provided criteria, an error in the script is always considered a failure.

From the embedded Lua interpreter there are two values set that can be accessed:

  • whenever_task is the name of the task that executes the script
  • whenever_condition is the name of the condition that triggered the task.

which might be useful if the scripts are aware of being run within whenever.

Conditions

Conditions are at the heart of whenever, by triggering the execution of tasks. As mentioned above, several types of condition are supported. Part of the configuration is common to all conditions, that is:

Entry Default Description
name N/A the unique name of the condition (mandatory)
type N/A string describing the type of condition (mandatory, one of the possible values)
recurring false if false, the condition is not checked anymore after first successful verification
execute_sequence true if true the associated tasks are executed one after the other, in the order in which they are listed
break_on_success false if true, task execution stops after the first successfully executed task (when execute_sequence is true)
break_on_failure false if true, task execution stops after the first failed task (when execute_sequence is true)
suspended false if true, the condition will not be checked nor the associated tasks executed
tasks [] a list of task names that will be executed upon condition verification

When execute_sequence is set to false, the associated tasks are started concurrently in the same instant, and task outcomes are ignored. Otherwise a minimal control flow is implemented, allowing the sequence to be interrupted after the first success or failure in task execution. Note that it is possible to set both break_on_success and break_on_failure to true.5

The type entry can be one of: "interval", "time", "idle", "command", "lua", "event", and "dbus". Any other value is considered a configuration error.

For conditions that should be periodically checked and whose associated task list has to be run whenever they occur (and not just after the first occurrence), the recurring entry can be set to true. Conditions with no associated tasks (eg. when the user comments out all the associated tasks in the configuration file) are not checked.

The suspended entry can assume a true value for conditions for which the user does not want to remove the configuration but should be (at least temporarily) prevented. However, a condition that is suspended by configuration can be awakened using an interactive command (usually by a wrapper): input commands passed via the stdin based interface can be used to suspend and resume condition checks when the scheduler is running.

There is another optional entry, namely tags, that is accepted in item configuration: this entry is ignored by whenever itself, however it is checked for correctness at startup and the configuration is refused if not set to an array (of strings) or a table.

Another entry is common to several condition types, that is check_after: it can be set to the number of seconds that whenever has to wait after startup (and after the last check for recurring conditions) for a subsequent check: this is useful for conditions that can run on a more relaxed schedule, or whose check process has a significant cost in terms of resources, or whose associated task sequence might take a long time to finish. Simpler conditions and conditions based on time do not accept this entry.

While a condition check or the execution of an associated task sequence is underway, the condition is marked as busy, and while a condition is in this state no further checks are performed. The condition is released from its busy state only after all checks and tasks have been performed. This is important when long-running checks and tasks are requested, as this flag ensures that checks and tasks for a single long-running and recurring activity cannot overlap.

Note that all listed tasks must be defined, otherwise an error is raised and whenever will not start.

The following paragraphs describe in detail each condition type. For the sake of brevity, only specific configuration entries will be described for each type.

All condition definition sections must start with the TOML [[condition]] header.

Interval

Interval based conditions are verified after a certain amount of time has passed, either since startup or after the last successful check. This type of condition is useful for tasks that should be executed periodically, thus most of the times recurring will be set to true for this type of condition. The following is an example of interval based condition:

[[condition]]
name = "IntervalConditionName"
type = "interval"
interval_seconds = 3600

# optional parameters (if omitted, defaults are used)
recurring = false
execute_sequence = true
break_on_failure = false
break_on_success = false
suspended = true
tasks = [
    "Task1",
    "Task2",
    ]

describing a condition that is verified one hour after whenever has started, and not anymore after the first occurrence -- because recurring is false here. Were it true, the condition would be verified every hour.

The specific parameters for this type of condition are:

Entry Default Description
type N/A has to be set to "interval" (mandatory)
interval_seconds N/A the number of seconds to wait for the condition to be verified (mandatory)

The check for this type of condition is never randomized.

Time

Time based conditions occur just after one of the provided time specifications has been reached. Time specifications are given as a list of tables, each of which can contain one or more of the following entries:

  • hour: the hour, as an integer between 0 and 23
  • minute: the minute, as an integer between 0 and 59
  • second: the second, as an integer between 0 and 59
  • year: an integer expressing the (full) year
  • month: an integer expressing the month, between 1 (January) and 12 (December)
  • day: an integer expressing the day of the month, between 1 and 31
  • weekday: the name of the weekday in English, either whole or abbreviated to three letters.

Not all the entries must be specified: for instance, specifying the day of week and a full date (as year, month, date) may cause the event to never occur if that particular date does not occur on that specific week day. Normally a day of the month will be specified, and then a time of the day, or a weekday and a time of the day. However full freedom is given in specifying or omitting part of the date:

  • missing parts in the date will be considered verified at every change of each of them (years, months, days, and weekdays)
  • a missing hour specification will be considered verified at every hour
  • a missing minute or second specification will be considered verified respectively at the first minute of the hour and first second of the minute.

Of course, all the time specifications in the provided list will be checked at each tick: this allows complex configurations for actions that must be performed at specific times.

A sample configuration section follows:

[[condition]]
name = "TimeConditionName"
type = "time"                               # mandatory value

# optional parameters (if omitted, defaults are used)
time_specifications = [
    { hour = 17, minute = 30 },
    { hour = 12, minute = 0, weekday = "wed" },
    ]
recurring = true
execute_sequence = true
break_on_failure = false
break_on_success = false
suspended = true
tasks = [
    "Task1",
    "Task2",
    ]

for a condition that is verified everyday at 5:30PM and every Wednesday at noon. The specific parameters are:

Entry Default Description
type N/A has to be set to "time" (mandatory)
time_specifications {} a list of partial time specifications, as inline tables consisting of the above described entries (mandatory)

The check for this type of condition is never randomized.

Idle session

Conditions of the idle type are verified after the session has been idle (that is, without user interaction), for the specified number of seconds.6 This does normally not interfere with other idle time based actions provided by the environment such as screensavers, and automatic session lock. The following is a sample configuration for this type of condition:

[[condition]]
name = "IdleConditionName"
type = "idle"
idle_seconds = 3600

# optional parameters (if omitted, defaults are used)
recurring = true
execute_sequence = true
break_on_failure = false
break_on_success = false
suspended = true
tasks = [
    "Task1",
    "Task2",
    ]

for a condition that will be verified each time that an hour has passed since the user has been away from the mouse and the keyboard. For tasks that usually occur only once per session when the workstation is idle (such as backups, for instance), recurring can be set to false. The table below describes the specific configuration entries:

Entry Default Description
type N/A has to be set to "idle" (mandatory)
idle_seconds N/A the number of idle seconds to be waited for in order to consider the condition verified (mandatory)

The check for this type of condition is never randomized.

Command

This type of condition gives the possibility to execute an OS command and decide whether or not the condition is verified testing the command exit code and/or what the command writes on its standard output or standard error channel. The available checks are of the same type as the ones available for command based tasks. In fact it is possible to:

  • identify a provided exit code as a failure or as a success
  • specifying that the presence of a substring or matching a regular expression corresponds to either a failure or a success.

Of course only a success causes the corresponding tasks to be executed: as for command based tasks, it is not mandatory to follow the usual conventions -- this means, for instance, that a zero exit code can be identified as a failure and a non-zero exit code as a success. A non-success has the same effect as a failure on task execution.

If a command is known to have the possibility to hang, a timeout can be configured by specifying the maximum number of seconds to wait for the process to exit: after this amount of time the process is terminated and fails.

An example of command based condition follows:

[[condition]]
name = "CommandConditionName"
type = "command"                            # mandatory value

startup_path = "/some/startup/directory"    # must exist
command = "executable_name"
command_arguments = [
    "arg1",
    "arg2",
    ]

# optional parameters (if omitted, defaults are used)
recurring = false
execute_sequence = true
break_on_failure = false
break_on_success = false
suspended = false
tasks = [
    "Task1",
    "Task2",
    ]
check_after = 10

match_exact = false
match_regular_expression = false
success_stdout = "expected"
success_stderr = "expected_error"
success_status = 0
failure_stdout = "unexpected"
failure_stderr = "unexpected_error"
failure_status = 2
timeout_seconds = 60
case_sensitive = false
include_environment = true
set_environment_variables = true
environment_variables = { VARNAME1 = "value1", VARNAME2 = "value2" }

The following table illustrates the parameters specific to command based conditions:

Entry Default Description
type N/A has to be set to "interval" (mandatory)
check_after (empty) number of seconds that have to pass before the condition is checked the first time or further times if recurring is true
startup_path N/A the directory in which the command is started (mandatory)
command N/A path to the executable (mandatory; if the path is omitted, the executable should be found in the search PATH)
command_arguments N/A arguments to pass to the executable: can be an empty list, [] (mandatory)
match_exact false if true, the entire output is matched instead of searching for a substring
match_regular_expression false if true, the match strings are considered regular expressions instead of substrings
case_sensitive false if true, substring search or match and regular expressions match is performed case-sensitively
timeout_seconds (empty) if set, the number of seconds to wait before the command is terminated (with unsuccessful outcome)
success_status (empty) if set, when the execution ends with the provided exit code the condition is considered verified
failure_status (empty) if set, when the execution ends with the provided exit code the condition is considered failed
success_stdout (empty) the substring or RE to be found or matched on stdout to consider the task successful
success_stderr (empty) the substring or RE to be found or matched on stderr to consider the task successful
failure_stdout (empty) the substring or RE to be found or matched on stdout to consider the task failed
failure_stderr (empty) the substring or RE to be found or matched on stderr to consider the task failed
include_environment true if true, the command is executed in the same environment in which whenever was started
set_environment_variables true if true, whenever sets environment variables reporting the names of the task and the condition
environment_variables {} extra variables that might have to be set in the environment in which the provided command runs

If set_environment_variables is true, whenever sets the following environment variable:

  • WHENEVER_CONDITION to the unique name of the condition that is currently being tested

for scripts or other executables used in checks that might be aware of whenever.

For this type of condition the actual test can be performed at a random time within the tick interval.

Lua script

A Lua script can be used to determine the verification of a condition: after the execution of the script, one or more variables can be checked against expected values and thus decide whether or not the associated tasks have to be run. Given the power of Lua and its standard library, this type of condition can constitute a lightweight alternative to complex scripts to call to implement a command based condition. The definition of a Lua condition is actually much simpler:

[[condition]]
name = "LuaConditionName"
type = "lua"                                # mandatory value
script = '''
    log.info("hello from Lua");
    result = 10;
    '''

# optional parameters (if omitted, defaults are used)
recurring = false
execute_sequence = true
break_on_failure = false
break_on_success = false
suspended = false
tasks = [
    "Task1",
    "Task2",
    ]
check_after = 10
expect_all = false
expected_results = { result = 10 }

The specific parameters are described in the following table:

Entry Default Description
type N/A has to be set to "lua" (mandatory)
check_after (empty) number of seconds that have to pass before the condition is checked the first time or further times if recurring is true
script N/A the Lua code that has to be executed by the internal interpreter (mandatory)
expect_all false if true, all the expected results have to be matched to consider the task successful, otherwise at least one
expected_results {} a dictionary of variable names and their expected values to be checked after execution

The same rules and possibilities seen for Lua based tasks also apply to conditions: the embedded Lua interpreter is enriched with library functions that allow to write to the whenever log, at all logging levels (error, warn, info, debug, trace). The library functions are the following:

  • log.error
  • log.warn
  • log.info
  • log.debug
  • log.trace

and take a single string as their argument. Also, from the embedded Lua interpreter there is a value that can be accessed:

  • whenever_condition is the name of the condition being checked.

External scripts can be executed via dofile("/path/to/script.lua") or by using the require function. While a successful execution is always determined by matching the provided criteria, an error in the script is always considered a failure.

For this type of condition the actual test can be performed at a random time within the tick interval.

DBus method

The return message of a DBus method invocation is used to determine the execution of the tasks associated to this type of condition. Due to the nature of DBus, the configuration of a DBus based condition is quite complex, both in terms of definition of the method to be invoked, especially for what concerns the parameters to be passed to the method, and in terms of specifying how to test the result.7 One of the most notable difficulties consists in the necessity to use embedded JSON2 in the TOML configuration file: this choice arose due to the fact that, to specify the arguments to pass to the invoked methods and the criteria used to determine the invocation success, non-homogeneous lists are needed -- which are not supported, intentionally, by TOML.

So, as a rule of thumb:

  • arguments to be passed to the DBus method are specified in a string containing the exact JSON representation of those arguments
  • criteria to determine expected return values (which can be complex structures) are expressed as inline tables of three elements, that is:
    • "index": a list of elements, which can be either integers or strings (the first one is always an integer) representing each a positional 0-based index or a string key in a dictionary; this allows to index deeply nested structures in which part of the nested elements are dictionaries
    • "operator": one of the following strings
      • "eq" for equality
      • "neq" for inequality
      • "gt" meaning greater than
      • "ge" meaning greater or equal to
      • "lt" meaning less than
      • "le" meaning less or equal to
      • "match" to indicate that the second operand has to be intended as a regular expression to be matched
      • "contains" to indicate that the second operand is contained in the first operand (see below)
      • "ncontains" to indicate that the second operand is not contained in the first operand
    • "value": the second operand for the specified operator.

Note that not all types of operand are supported for all operators: comparisons (greater and greater or equal, less and less or equal) are only supported for numbers, and matching is only supported for strings. The "contains"/"ncontains" operators support non-structured types for the second operand (booleans, numbers, and strings) and either strings (and object paths) or arrays for the first one: if the first operand is an array the second operand is searched in the list and the check is true when it is found, if the first operand is either a string or an object path, the check is true when the second one is a substring. Also, comparisons always fail for incompatible operands: integers can only be compared with integers, floating point numbers with floating point numbers and strings with strings -- no automatic type conversion is performed. This also yields for attempts to find a value in an array: an integer will never be found in an array of floating point numbers, and so on. To be consistent with the rule of unsuccessfulness on incompatible operands, the "ncontains" operator too leads to unsuccessful tests when the operands cannot be compared, although the opposite could seem appropriate.

A further difficulty is due to the fact that, while DBus is strictly typed and supports all the basic types supported by C and C++, neither TOML nor JSON do. Both (and especially JSON, since it is used for invocation purpose in whenever) support more generic types, which are listed below along with the DBus type to which whenever converts them in method invocation:

  • Boolean: BOOLEAN
  • Integer: I64
  • Float: F64
  • String: STRING
  • List: ARRAY
  • Map: DICTIONARY

This means that there are a lot of value types that are not directly derived from the native JSON types. whenever comes to help by allowing to express strictly typed values by using specially crafted strings. These string must begin with a backslash, \, followed by the signature character (ASCII Type Code in the basic type table8) identifying the type. For example, the string "\\y42" indicates a BYTE parameter holding 42 as the value, while "\\o/com/example/MusicPlayer1" indicates an OBJECT_PATH9 containing the value /com/example/MusicPlayer1. A specially crafted string will be translated into a specific value of a specific type only when a supported ASCII Type Code is used, in all other cases the string is interpreted literally: for instance, "\\w100" is translated into a STRING holding the value \w100.

For return values, while the structure of complex entities received from DBus is kept, all values are automatically converted to more generic types: a returned BYTE is converted to a JSON Integer, and a returned OBJECT_PATH is consdered a JSON String which, as a side effect, supports the "match" operator.

An example of DBus method based condition follows:

[[condition]]
name = "DbusMethodConditionName"
type = "dbus"                       # mandatory value
bus = ":session"                    # either ":session" or ":system"
service = "org.freedesktop.DBus"
object_path = "/org/freedesktop/DBus"
interface = "org.freedesktop.DBus"
method = "NameHasOwner"

# optional parameters (if omitted, defaults are used)
recurring = false
execute_sequence = true
break_on_failure = false
break_on_success = false
suspended = true
tasks = [ "Task1", "Task2" ]
check_after = 60
parameter_call = """[
        "SomeObject",
        [42, "a structured parameter"],
        ["the following is an u64", "\\t42"]
    ]"""
parameter_check_all = false
parameter_check = """[
         { "index": 0, "operator": "eq", "value": false },
         { "index": [1, 5], "operator": "neq", "value": "forbidden" },
         {
             "index": [2, "mapidx", 5],
             "operator": "match",
             "value": "^[A-Z][a-zA-Z0-9_]*$"
         }
    ]"""

As shown below, parameter_check is the list of criteria against which the return message parameters (the invocation results are often referred to with this terminology in DBus jargon): for what has been explained above, the checks are performed like this:

  1. the first element (thus with 0 as index) of the returned array is expected to be a boolean and to be false
  2. the second element is considered to be an array, whose sixth element (with index 5) must not be the string "forbidden"
  3. the third element is highly nested, containing a map whose element with key "mapidx" is an array, containing a string at its sixth position, which should be alphanumeric and begin with a capital letter, and may contain underscores (that is, matches the regular expression ^[A-Z][a-zA-Z0-9_]*$).

Note that the first check shows a 0 index not embedded in a list: if a returned parameter is not an array or a dictionary and its value is required directly, the square brackets around this single index can be omitted and whenever does not complain. Since this is probably the most frequent use case, this is a way to make configuration for such cases more readable and concise.

Since parameter_check_all is false, satisfaction of one of the provided criteria is sufficient to determine the success of the condition.

The specific parameters are described in the following table:

Entry Default Description
type N/A has to be set to "dbus" (mandatory)
check_after (empty) number of seconds that have to pass before the condition is checked the first time or further times if recurring is true
bus N/A the bus on which the method is invoked: must be either ":system" or ":session", including the starting colon (mandatory)
service N/A the name of the service that exposes the required object and the interface to invoke or query (mandatory)
object_path N/A the object exposing the interface to invoke or query (mandatory)
interface N/A the interface to invoke or query (mandatory)
method N/A the name of the method to be invoked (mandatory)
parameter_call (empty) a structure, expressed as inline JSON, containing exactly the parameters that shall be passed to the method
parameter_check_all false if true, all the returned parameters will have to match the criteria for verification, otherwise one match is sufficient
parameter_check (empty) a list of maps consisting of three fields each, each of which is a check to be performed on return parameters

The value corresponding to the service entry is often referred to as bus name in various documents: here service is preferred to avoid confusing it with the actual bus, which is either the session bus or the system bus.

Methods resulting in an error will always be considered as failed: therefore it is possible to avoid to provide return value criteria, and just consider a successful invocation as a success and an error as a failure.

Working on a file that mixes TOML and JSON, it is worth to remind that JSON supports inline maps distributed on multiple lines (see the example above, the third constraint) and that in JSON trailing commas are considered an error. Also, JSON does not support literal strings, therefore when using backslashes (for instance when specifying typed values with strings as described above), the backslashes themselves have to be escaped within the provided JSON strings.

Note that DBus based conditions are supported on Windows, however DBus should be running for such conditions to be useful -- which is very unlikely to say the least.

For this type of conditions the actual test can be performed at a random time within the tick interval.

Event based

Conditions that are fired by events are referred to here both as event conditions and as bucket conditions. The reason for the second name is that every time that whenever catches an event that has been required to be monitored, it tosses the associated condition in a sort of execution bucket, that is checked by the scheduler at every tick: the scheduler withdraws every condition found in the bucket and runs the associated tasks. In facts, these conditions only exist as a connection between the events, that occur asynchronously, and the scheduler. Their configuration is therefore very simple, as seen in this example:

[[condition]]
name = "BucketConditionName"
type = "bucket"         # "bucket" or "event" are the allowed values

# optional parameters (if omitted, defaults are used)
recurring = false
execute_sequence = true
break_on_failure = false
break_on_success = false
suspended = false
tasks = [
    "Task1",
    "Task2",
    ]

that is, these conditions have no specific fields, if not for the mandatory type that should be either "bucket" or "event" (with no operational difference, at least for the moment being):

Entry Default Description
type N/A has to be set to "bucket" or "event" (mandatory)

These conditions are associated to events for verification, no other criteria can be specified.

For this type of conditions the actual test can be performed at a random time within the tick interval.

Events

Three types of event are supported, at least for now. The reason is that while on Linux DBus handles the majority of the communication between the system and the applications, via a well described subscription mechanism, other environments provide a less portable interface -- even more aimed at usage through APIs that are directly coded in applications. However, in many cases specific checks involving command based conditions can be used to inspect the system status: for example, on Windows the reg command can be used to inspect the registry, and the wevtutil command to query the system event log.

One notable exception, which is also particularly useful, is the notification of changes in the filesystem for watched entities (files or directories), which is implemented in whenever as one of the possible events that can fire conditions, the other being DBus signals which are generally available on linux desktops (at least Gnome and KDE).

The third kind of events supported by whenever is based on its stdin based command interface. These events are directly raised by issuing a trigger command followed by the event name: a wrapper, even possibly a platform specific one, can therefore notify whenever that a specific event took place, or that the user explicitly required to trigger it from the available user interface. This type of event is the simplest one to define, as it has no criteria to be specified.

Note that if an event arises more that once within the tick interval, it is automatically debounced and a single occurrence is counted.

All event definition sections must start with the TOML [[event]] header.

An optional entry, namely tags, is accepted in item configuration: this entry is ignored by whenever itself, however it is checked for correctness at startup and the configuration is refused if not set to an array (of strings) or a table.

The associated conditions must exist, otherwise an error is raised and whenever aborts.

Filesystem changes

This type of event arises when there is a modification in the filesystems, regarding one of more monitored files and/or directories. whenever allows to monitor a list of items for each defined event of this type, and to associate an event based condition to the event itself. A sample configuration follows:

[[event]]
name = "FilesystemChangeEventName"
type = "fschange"
condition = "AssignedConditionName"

# optional parameters (if omitted, defaults are used)
watch = [
    "/path/to/resource",
    "/another/path/to/file.txt",
    ]
recursive = false
poll_seconds = 2

The configuration entries are:

Entry Default Description
name N/A the unique name of the event (mandatory)
type N/A must be set to "fschange" (mandatory)
condition N/A the name of the associated event based condition (mandatory)
watch N/A a list of items to be monitored: possibly expressed with their full path
recursive false if true, listed directories will be monitored recursively
poll_seconds 2 generally not used, can be needed on systems where the notification service is unavailable

DBus signals

DBus provides signals that can be subscribed by applications, to receive information about various aspects of the system status in an asynchronous way. whenever offers the possibility to subscribe to these signals, so that when the return parameters match the provided constraints, then the event occurs and the associated condition is fired.

Subscription is performed by providing a watch expression in the same form that is used by the dbus-monitor utility, therefore JSON is not used for this purpose. JSON is used instead to specify the criteria that the signal parameters must meet in order for the event to arise, using the same format that is used for return message parameter checks in DBus method based conditions.

A sample configuration section follows:

name = "DbusMessageEventName"
type = "dbus"                       # mandatory value
bus = ":session"                    # either ":session" or ":system"
condition = "AssignedConditionName"
rule = """\
    type='signal',\
    sender='org.freedesktop.DBus',\
    interface='org.freedesktop.DBus',\
    member='NameOwnerChanged',\
    arg0='org.freedesktop.zbus.MatchRuleStreamTest42'\
"""

# optional parameters (if omitted, defaults are used)
parameter_check_all = false
parameter_check = """[
         { "index": 0, "operator": "eq", "value": false },
         { "index": [1, 5], "operator": "neq", "value": "forbidden" },
         {
             "index": [2, "mapidx", 5],
             "operator": "match",
             "value": "^[A-Z][a-zA-Z0-9_]*$"
         }
    ]"""

and the details of the configuration entries are described in the table below:

Entry Default Description
name N/A the unique name of the event (mandatory)
type N/A must be set to "dbus" (mandatory)
condition N/A the name of the associated event based condition (mandatory)
bus N/A the bus on which to listen for events: must be either ":system" or ":session", including the starting colon (mandatory)
parameter_check_all false if true, all the returned parameters will have to match the criteria for verification, otherwise one match is sufficient
parameter_check (empty) a list of maps consisting of three fields each, each of which is a check to be performed on return parameters

The considerations about indexes in return parameters are the same that have been seen for DBus message based conditions.

If no parameter checks are provided, the event arises simply when the signal is caught.

Command line

As said above, this type of event has no other parameters than the name, the type identifier, and the associated condition. All parameters are mandatory. The event is raised when a wrapper (or the user) passes a trigger command to whenever through the stdin stream of an active session.

A sample configuration section follows:

name = "ManuallyTriggeredEvent"
type = "cli"                        # mandatory value
condition = "AssignedConditionName"

and the details of the configuration entries are described in the table below:

Entry Default Description
name N/A the unique name of the event (mandatory)
type N/A must be set to "cli" (mandatory)
condition N/A the name of the associated event based condition (mandatory)

No listening service is installed, so the impact on resource consumption and performance is almost unnoticeable.

Logging

Log messages are not dissimilar to the ones provided by servers and other applications running in the background: a date/time specification is reported, as well as the name of the application (whenever), the logging level to which the message line is pertinent, and then a message (the so-called payload). The message itself is structured: it consists of a short context specification, followed by a string enclosed in square brackets describing the nature of the message (for instance if the message is referred to the start or to the end of a process, and whether the message indicates a normal condition or something that went wrong). The context can be either the MAIN control program (or one of its threads), a TASK, a CONDITION, an EVENT or a REGISTRY -- there are many registries in whenever, used by the main control program to reach the item collections.

Logging is quite verbose in whenever at the trace log level, and can be very brief when enabling logging just for warnings and errors.

A short description of the log levels follows:

  1. trace: every single step is logged, some messages can be redundant because if an acknowledgement or an issue takes place in more than one context of the program, each of the involved parts may decide to log about what happened. Sometimes, for example, the same error may be reported by a condition that is checked and by the registry that has been used to reach this condition. Also, history messages are issued only at the trace level: wrappers will want to use the trace level in order to catch these messages and calculate, for instance, the execution time for a particular task.
  2. debug: there is plenty of informational messages at each execution step, however redundant messages are not emitted. In particular, history messages are not present at this level.
  3. info: a reduced amount of informational messages is emitted, mostly related to the outcome of conditions and execution of related tasks; information about what is being checked is less verbose. Very reduced logging is performed at this level by the main control program, thus most of the logging is left to items.
  4. warn: only warnings are logged, erratic situations that can be handled by whenever without having to stop or abort -- except for termination requests, which are logged as warnings instead of errors, even though they could be considered normal causes for the scheduler to stop and exit.
  5. error: only errors are reported, which are erratic situations that may prevent whenever to perform the requested operations or, in some cases, to keep running correctly.

Note that, since Lua scripts are allowed to log at each of the above described levels, lines emitted by Lua script might not always correspond to what is illustrated above.

As mentioned above, just after the context, in the message payload, a string of the form [WHEN/STATUS] appears that can be used to better identify of the message, where

  • WHEN represents the nature of the log entry, and can be one of

    • INIT when the message is related to an initialization phase (mostly around startup)
    • START when the message is issued when starting something, for instance a check or a new process
    • PROC when the message is issued in the middle of something, for instance while executing a check
    • END when the message is emitted at the end of something, before returning control
    • HIST when the message is intended for some receiver (generally a wrapper) that keeps track of the history: in this case the outcome is either START or END
  • STATUS holds the outcome of the current activity, and is one of the following:

    • OK for expected behaviours
    • FAIL for unexpected behaviours
    • IND when the outcome of an operation is undetermined
    • MSG when the message is merely informational
    • ERR when an operation fails with an error
    • START/END are pseudo-outcomes that only occur when the nature is HIST, to mark the beginning or the end of an activity

This string appears before a human-readable message, so that it can be used by a wrapper to filter or highlight message when displaying the log -- completely or partially. Sometimes it might seem that the expression in square bracket conflicts with the message body, a notable example being a message similar to

[2023-06-20T21:53:45.089] (whenever) INFO  CONDITION Cond_INTERVAL/6: [PROC/OK] failure: condition checked with negative outcome

while in fact this kind of message is absolutely legitimate: a negative outcome in condition checking is expected quite often, this is the reason why the message documenting a failed check is reported as a positive ([PROC/OK]) log entry.

There is an option that can be specified on the command line, that forces the log lines to be emitted in the JSON format: this allows to separate the parts more easily into a header ("header" field) that holds

  • the log timestamp ("time")
  • the application name ("application")
  • log level ("level")

followed by the actual log contents, consisting of

  • context ("context"), which contains
    • the part of the scheduler that emits the message ("emitter")
    • the action that is being performed, or the indication that an item is active ("action")
    • the name and unique id of the item, if the message concerns an activated item ("item" and "item_id")
  • the message type ("message_type"), consisting of
    • the nature of the message ("when")
    • the current outcome ("status")
  • a human readable message ("message")

in order to better handle the logs and to provide feedback to the user. A sample JSON record is shown below:

{
    "header": {
        "application": "whenever",
        "level": "TRACE",
        "time": "2023-11-04T11:17:25.257970"
    },
    "contents": {
        "context": {
            "action": "scheduler_tick",
            "emitter": "MAIN",
            "item": null,
            "item_id": null
        },
        "message": "condition Cond_TIME tested with no outcome (tasks not executed)",
        "message_type": {
            "status": "MSG",
            "when": "PROC"
        }
    }
}

The actual log record, also in JSON format, is emitted in the form of a single text line.

Input commands

As said above, whenever accepts commands (in the form of command lines) through the standard input. Actually, no prompt is shown, and the console log will keep showing up continuously even when an user types any interactive command: in fact the stdin based interface is mainly aimed at wrapping whenever into a graphical shell that could use these commands to control the scheduler.

A command line is intended as one of the commands in the table below, possibly followed by one or more arguments, when supported, separated by whitespace and terminated by a carriage return -- meaning that '\n' must be used at the end of the line when sending a command from the wrapper. Unsupported commands or arguments cause whenever to log an error, however the offending command line is just ignored with no other side effects.

The available commands are:

Command Arguments Action
pause none the scheduler keeps running, but all checks are suspended
resume none resume from a paused state: enabled conditions are checked again
exit (or quit) none shut down whenever, waiting for running activity to finish
kill none shut down whenever immediately, terminating all current activity
reset_conditions [Cond1 [Cond2 [...]]] reset the state of specified conditions: the optional arguments are names of conditions to be reset (all by default)
suspend_condition Condition suspend the specified condition: the condition name argument is mandatory
resume_condition Condition resume the specified condition from a suspended state: the condition name argument is mandatory
trigger Event trigger the specified event causing the associated conditions to fire

The pause command is ignored in paused state, and resume is ignored otherwise. Attempts to suspend conditions that are already suspended or to resume already active conditions are also ignored. Typing exit or quit followed by a carriage return on the console window where whenever is running has almost the same effect as hitting Ctrl+C. The reset_conditions command resets the internal state of all configured conditions when no arguments are provided. The trigger command can only receive the name of a Command line event as an argument: other uses will cause the command to be ignored and an error or a warning to be logged.

Note: resetting the internal state of a condition indicates that, after the operation, the condition has the same state as when the scheduler just started. It mostly has effect on interval based conditions and conditions that are not recurring. In the first case, the condition operates as if the interval counter has started in the instant of its reset. The second case is actually more interesting, as the success state is taken back to an undetermined state, and thus the scheduler starts checking the condition again even if it had succeeded before. A condition that is resumed using the resume_condition command also receives a reset, so that conditions that depend on waiting for a certain amount of time to fire do not count the time spent in suspended state as part of the time to wait for.

Build issues

whenever is being thoroughly tested on Windows and Linux. It should work on Mac too, although I have no possibility to test it personally. On Windows, the build process is seamless as long as Rust and all its dependencies are installed.

On Linux, the build process might complain that some packages are missing: it mainly occurs because the "essential build tools" and the development versions of some packages are not installed by default. Taking Debian as an example, the following packages must be installed:

  • pkg-config
  • libx11-dev
  • libdbus-1-dev
  • libxss-dev
  • xscreensaver (not always necessary).

With these packages installed, the scheduler compiles without errors. However, since not all Linux distributions come with Xscreensaver support (which is used to determine idle time), the related condition might not be checked and never fire. There is a DBus based workaround, that allows to use Idle session conditions on Linux: the idle time, in this case, is counted as the amount of seconds after the screen has been locked. To enable this workaround, just edit the Cargo.toml file by uncommenting the second of the following lines:

# user-idle has a problem on wayland-based sessions: work around by using
user-idle = { version = "0.5.3", default-features = false, features = ["dbus"] }  # <-- this line must be uncommented
# user-idle = "0.5.3"                                                             # <-- this line must be commented

and commenting the line below.

Conclusion

The configuration of whenever might be difficult, especially for complex aspects such as events and conditions based on DBus. In this sense, since whenever does not provide a GUI, the features of the Python based When are not completely matched. However, this happens to be a significant step towards solution of issue #85 in the Python version. Moreover, whenever gains some useful features (such as the Lua embedded interpreter) in this transition, as well as the possibility of running on many platforms instead of being confined to a restricted number of versions of Ubuntu Linux, and the very low impact on the system in terms of resource usage.

I am considering whenever as the evolution of the When operational engine, and future versions of When itself (which will bump its version number to something more near to the awaited 2.0.0) will only implement the GUIs that might (or might not) be used to configure whenever and to control it from the system tray in a more sophisticated way than the one allowed by the minimal C++ based utility.

License

This tool is licensed under the LGPL v2.1 (may change to LGPL v3 in the future): see the provided LICENSE file for details.

Footnotes

  1. Although DBus support is available on Windows too, it is mostly useful on Linux desktops.

  2. Because TOML is sometimes too strict and is not able to represent certain types of structured data, JSON is used in some cases within the TOML configuration file. 2

  3. When run alone, with no wrapper: using the minimal provided wrapper, both programs together use less than 4MB of RAM and the combined CPU consumption in rare occasions reaches the 0.2% -- as reported by the Windows Task Manager.

  4. The occurrence of an event, in fact, raises a flag that specifies that the associated condition will be considered as verified at the following tick: the condition is said to be thrown in a sort of "execution bucket", from which it is withdrawn by the scheduler that executes the related tasks. Therefore event based conditions are also referred to as bucket conditions.

  5. In this case the execution will continue as long as the outcome is undefined until the first success or failure happens.

  6. Except on Wayland based Linux systems (e.g. Ubuntu), on which the idle time starts after the session has been locked.

  7. In fact, in the original When the DBus based conditions and events were considered an advanced feature: even the dialog box that allowed the configuration of user-defined DBus events was only available through a specific invocation using the command line.

  8. See the DBus Specification for the complete list of supported types, and the ASCII character that identifies each of them.

  9. in DBus, strings and object paths are considered different types.

About

The Whenever task scheduler

License:GNU Lesser General Public License v2.1


Languages

Language:Rust 100.0%