Skip to content

Latest commit

 

History

History
669 lines (516 loc) · 21.9 KB

File metadata and controls

669 lines (516 loc) · 21.9 KB

Scheduler

The Visor scheduler provides a generic, frontend-agnostic system for executing workflows and reminders at specified times. It supports both static schedules defined in YAML configuration and dynamic schedules created via AI tool at runtime.

Overview

The scheduler operates in two modes:

  1. Workflow Schedules: Execute a named workflow/check from your configuration
  2. Simple Reminders: Post a message or run it through the visor pipeline (e.g., for AI-powered responses)

Output destinations (Slack, GitHub, webhooks) are handled by output adapters, making the scheduler truly frontend-agnostic. When running with Slack, the SlackOutputAdapter automatically posts results back to the appropriate channel or DM.

Configuration

Storage & Cloud Databases: For detailed database configuration (PostgreSQL, MySQL, MSSQL, SSL/TLS, connection strings, cloud provider examples), see Scheduler Storage.

Add scheduler settings to your .visor.yaml:

scheduler:
  enabled: true
  storage:
    path: .visor/schedules.json
  default_timezone: America/New_York
  check_interval_ms: 60000  # How often to check for due schedules

  # Limits for dynamic schedules (created via AI tool)
  limits:
    max_per_user: 25
    max_recurring_per_user: 10
    max_global: 1000

  # Permissions for dynamic schedule creation
  permissions:
    allow_personal: true      # Allow schedules via DM or CLI
    allow_channel: true       # Allow channel schedules (Slack)
    allow_dm: true            # Allow DM schedules to other users
    allowed_workflows:        # Glob patterns for allowed workflows
      - "report-*"
      - "status-*"
    denied_workflows:         # Glob patterns for denied workflows
      - "admin-*"
      - "dangerous-*"

  # Static cron jobs (always allowed, bypass permissions)
  cron:
    daily-standup:
      schedule: "0 9 * * 1-5"  # Weekdays at 9am
      workflow: daily-standup
      timezone: America/New_York
      output:
        type: slack
        target: "#engineering"

    weekly-report:
      schedule: "0 10 * * 1"   # Mondays at 10am
      workflow: weekly-report
      inputs:
        team: platform
      output:
        type: slack
        target: "#platform-team"

Message Triggers (on_message)

Message triggers allow workflows to be executed reactively based on Slack messages, without requiring an @mention. This is useful for monitoring channels (e.g., CI/CD failure notifications) and automatically triggering workflows in response.

Configuration

Add on_message alongside cron in the scheduler section:

scheduler:
  enabled: true

  on_message:
    cicd-watcher:
      description: "React to CI/CD failure notifications"
      channels: ["C0CICD"]           # Channel IDs (wildcard suffix supported, e.g., "CENG*")
      from: ["U123BOT"]              # Optional: only from these user IDs
      from_bots: true                # Allow bot messages (default: false)
      contains: ["failed", "error"]  # Any keyword match, case-insensitive (OR)
      match: "build.*failed"         # Regex pattern (optional)
      threads: root_only             # root_only | thread_only | any (default: any)
      workflow: handle-cicd-failure
      inputs:
        source: slack
      output:
        type: slack
      enabled: true

    thread-responder:
      description: "Respond in threads about deployments"
      channels: ["C0DEPLOY"]
      threads: thread_only           # Only react to thread replies
      contains: ["help", "stuck"]
      workflow: deploy-help

Filter Options

Option Type Default Description
channels string[] (all) Channel IDs to monitor. Supports wildcard suffix (e.g., CENG*)
from string[] (all) Only trigger on messages from these Slack user IDs
from_bots boolean false Allow bot messages to trigger. When false, bot messages are ignored
contains string[] (all) Keyword match — any keyword triggers (case-insensitive, OR logic)
match string (none) Regex pattern to match against message text (case-insensitive)
threads string any Thread scope: root_only, thread_only, or any
workflow string (required) Workflow/check ID to execute when triggered
inputs object {} Workflow inputs passed to the execution
output object (none) Output destination (type, target, thread_id)
description string (none) Description for logging
enabled boolean true Enable/disable this trigger

Filter Logic

  • All specified filters must pass (AND logic between filters)
  • Within contains, any keyword match suffices (OR logic)
  • Omitted filters are not checked (they match everything)

Thread Scope

Value Behavior
root_only Only react to root channel messages (no thread_ts or thread_ts === ts)
thread_only Only react to thread replies (thread_ts exists and differs from ts)
any React to both root messages and thread replies (default)

When a triggered message is a thread reply, full thread conversation context is fetched via SlackAdapter and passed to the workflow as slack_conversation. When the message is a root channel message, only the single message is available.

Message Flow

Slack WebSocket Event
  → Parse JSON, ACK
  → Filter subtypes (edited, etc.)
  → Filter self-bot messages
  → Filter guests
  → Evaluate message triggers ← NEW
       → For each match: dispatch workflow async
  → Mention gate (existing @mention path)
  → Thread/allowlist/dedup gates
  → Dispatch all checks (existing mention-based path)

Both paths can fire for the same message. A message that matches a trigger AND contains an @mention will dispatch the triggered workflow AND go through the normal mention-based dispatch. Each triggered workflow gets its own engine instance.

Webhook Context

The triggered workflow receives via webhookData:

Field Description
event.text Message text
event.user Sender user ID
event.channel Channel ID
event.ts / event.thread_ts Message timestamps
trigger.id Which trigger matched
trigger.type on_message
slack_conversation Full thread context (if thread reply)

The event type is slack_message, which can be used in step on: filters:

steps:
  handle-cicd-failure:
    type: ai
    on: [slack_message]
    prompt: "Analyze this CI/CD failure and suggest fixes."

Deduplication

Each trigger match is deduplicated using the key trigger:${triggerId}:${channel}:${ts}. This prevents the same trigger from firing multiple times for duplicate Slack events.

Hot Reload

Message triggers are rebuilt when configuration is hot-reloaded via updateConfig(). Adding, removing, or modifying triggers in .visor.yaml takes effect on the next config reload without restarting the bot.

Example: CI/CD Failure Monitor

scheduler:
  on_message:
    cicd-failures:
      channels: ["C0CICD"]
      from_bots: true
      contains: ["failed", "error", "broken"]
      match: "build.*#\\d+.*(failed|error)"
      threads: root_only
      workflow: analyze-failure
      inputs:
        notify_team: true

steps:
  analyze-failure:
    type: ai
    on: [slack_message]
    schema:
      type: object
      properties:
        text:
          type: string
        severity:
          type: string
          enum: [low, medium, high, critical]
      required: [text, severity]
    prompt: |
      A CI/CD failure was detected. Analyze the failure message and provide:
      1. Root cause analysis
      2. Suggested fix
      3. Severity assessment

      Failure message: {{ event.text }}

Static Cron Jobs

Static cron jobs are defined in your configuration file and always run regardless of permission settings. They're ideal for recurring organizational tasks:

scheduler:
  cron:
    security-scan:
      schedule: "0 2 * * *"  # Daily at 2am
      workflow: security-scan
      output:
        type: slack
        target: "#security-alerts"

    backup-status:
      schedule: "0 6 * * *"  # Daily at 6am
      workflow: backup-check
      inputs:
        notify_on_failure: true

Cron Expression Format

Standard 5-field cron expressions:

  • * * * * * - minute, hour, day of month, month, day of week
  • 0 9 * * * - Every day at 9:00 AM
  • 0 9 * * 1-5 - Weekdays at 9:00 AM
  • */15 * * * * - Every 15 minutes
  • 0 0 1 * * - First day of every month at midnight

Dynamic Schedules (AI Tool)

Users can create schedules dynamically through the AI tool. The AI is responsible for:

  1. Extracting timing: Converting natural language to cron expressions or ISO timestamps
  2. Determining targets: Using the current channel context (channel ID from conversation)
  3. Identifying recurrence: One-time vs recurring schedules

Example Interactions

User in DM: "remind me to check builds every day at 9am"
AI: [calls schedule tool with action=create, reminder_text="check builds",
     cron="0 9 * * *", target_type="dm", target_id="D09SZABNLG3"]

User in #security: "run security-scan every Monday at 10am"
AI: [calls schedule tool with action=create, workflow="security-scan",
     cron="0 10 * * 1", target_type="channel", target_id="C05ABC123"]

User in DM: "remind me in 2 hours to review the PR"
AI: [calls schedule tool with action=create, reminder_text="review the PR",
     run_at="2026-02-08T18:00:00Z", target_type="dm", target_id="D09SZABNLG3"]

AI Tool Parameters

The AI generates structured parameters:

Parameter Description
reminder_text What to say when the schedule fires
workflow Alternatively, a workflow to execute
target_type "channel", "dm", "thread", or "user"
target_id Slack channel ID (C... or D...)
cron For recurring: cron expression
run_at For one-time: ISO 8601 timestamp
is_recurring Boolean flag

Permission Controls

Dynamic schedules respect the permissions configuration:

  • allow_personal: Controls personal schedules (DM context or CLI)
  • allow_channel: Controls channel schedules (Slack channels)
  • allow_dm: Controls DM schedules to other users
  • allowed_workflows: Glob patterns that workflows must match
  • denied_workflows: Glob patterns that block workflows (checked first)
permissions:
  allow_personal: true
  allow_channel: false     # Disable channel schedules
  allow_dm: false          # Disable DM schedules
  allowed_workflows:
    - "report-*"           # Only allow report workflows

Schedule Types and Context Restrictions

The scheduler determines schedule type based on context and enforces restrictions:

Context Allowed Schedule Type
CLI personal only
Slack DM personal only
Slack channel channel only
Slack group DM dm only

Context-Based Enforcement: When creating a schedule from a DM, you can only create personal schedules. When in a channel, you can only create channel schedules. This prevents cross-context leakage (e.g., personal reminders shouldn't appear when listing schedules in a public channel).

List Filtering

When listing schedules, only schedules matching the current context type are shown:

  • In a DM: Only personal schedules
  • In a channel: Only channel schedules
  • In a group DM: Only dm/group schedules

This protects privacy - personal reminders created in a DM won't be visible when someone lists schedules in a public channel.

CLI Commands

Start the Scheduler Daemon

visor schedule start [--config .visor.yaml]

Runs the scheduler daemon that checks for and executes due schedules.

List Schedules

visor schedule list [--user <userId>] [--status <status>] [--json]

Shows schedules. Use --json for machine-readable output.

Create a Schedule

visor schedule create <workflow> --at "<expression>" [--inputs key=value] [--output-type slack] [--output-target #channel]

Examples:

# One-time schedule
visor schedule create daily-report --at "tomorrow at 9am"

# Recurring schedule
visor schedule create standup --at "every weekday at 9am" --output-type slack --output-target "#team"

# With inputs
visor schedule create backup-check --at "every day at 2am" --inputs environment=production

Cancel a Schedule

visor schedule cancel <id>

Pause/Resume

visor schedule pause <id>
visor schedule resume <id>

Output Adapters

When a schedule executes, results can be routed to different destinations:

Slack Output

output:
  type: slack
  target: "#channel-name"  # or @username for DM
  thread_id: "1234567890.123456"  # Optional thread

GitHub Output

output:
  type: github
  target: "owner/repo"

Webhook Output

output:
  type: webhook
  target: "https://example.com/webhook"

No Output

output:
  type: none

Integration with Slack

When running the Slack bot, the scheduler automatically starts and integrates with the Slack frontend:

// In socket-runner.ts
import { Scheduler, createSlackOutputAdapter } from '../scheduler';

// Create scheduler
this.genericScheduler = new Scheduler(this.visorConfig, schedulerConfig);

// Set execution context so scheduled reminders can use the Slack client
this.genericScheduler.setExecutionContext({
  slack: this.client,
  slackClient: this.client,
});

// Register Slack output adapter for posting results
this.genericScheduler.registerOutputAdapter(
  createSlackOutputAdapter(this.client)
);

await this.genericScheduler.start();

Execution Context

The scheduler uses an execution context to pass runtime dependencies to workflow executions:

  • slackClient: The Slack API client for posting messages
  • cliMessage: For simple reminders, this bypasses human-input prompts

First Message Seeding

For simple reminders, the scheduler seeds the PromptStateManager so that human-input checks can consume the reminder text as if the user sent it:

const mgr = getPromptStateManager();
mgr.setFirstMessage(channel, threadTs, reminderText);

This ensures reminders run through chat workflows smoothly without blocking for user input.

Natural Language Parsing

The scheduler understands various time expressions:

One-time

  • "in 2 hours"
  • "in 30 minutes"
  • "tomorrow at 9am"
  • "next Monday at 3pm"
  • "Friday at noon"

Recurring

  • "every day at 9am"
  • "every Monday at 9am"
  • "every weekday at 9am"
  • "every hour"
  • "every 30 minutes"
  • "every month on the 1st at midnight"

Schedule Lifecycle

  1. Created: Schedule is stored with status active
  2. Due: When current time >= nextRunAt, schedule is picked up
  3. Executing: Workflow runs with schedule context
  4. Completed:
    • One-time: status changes to completed
    • Recurring: nextRunAt is updated, status stays active
  5. Paused: Schedule is skipped during checks
  6. Failed: Increments failureCount, may be retried

Simple Reminders (No Workflow)

When a schedule has no workflow specified but includes workflowInputs.text, it runs as a "simple reminder":

  1. The reminder text is treated as if the user sent it as a new message
  2. It runs through the full visor pipeline (all configured checks)
  3. The AI processes it and posts the response back via the Slack frontend
  4. The SlackOutputAdapter detects when the pipeline handled output and avoids double-posting

This allows reminders like "check how many Jira tickets were created this week" to get an AI-generated response rather than just echoing the reminder text.

# Example: Schedule created via AI tool
# When this fires, it runs through the pipeline and posts the AI response
{
  "workflowInputs": { "text": "How many PRs were merged today?" },
  "outputContext": { "type": "slack", "target": "D09SZABNLG3" }
}

Response Continuity (previousResponse)

For recurring simple reminders, the scheduler saves the AI response after each execution. On subsequent runs, the previous response is included in the context, allowing the AI to reference or build upon its earlier answer.

How it works:

  1. Reminder fires and runs through the visor pipeline
  2. AI generates a response, which is posted to Slack
  3. The response text is saved as previousResponse in the schedule store
  4. On the next run, the reminder text includes the previous response:
    <original reminder text>
    
    ---
    **Previous Response (for context):**
    <AI's previous response>
    ---
    
    Please provide an updated response based on the reminder above.
    

Example use case:

User: "Every day at 9am, tell me how many Jira tickets were created"

Day 1: AI responds with "5 tickets were created yesterday"
Day 2: AI sees previous response and can say "8 tickets today (up from 5 yesterday)"

This feature enables continuity for status updates, progress tracking, and any recurring reminder where historical context is valuable. The AI can:

  • Compare current data to previous runs
  • Track trends over time
  • Provide delta/change information
  • Reference what was said before

Note: One-time schedules do not save previousResponse since they only execute once.

Architecture

File Structure

src/
├── scheduler/                    # Generic scheduler module
│   ├── index.ts                  # Public exports
│   ├── schedule-store.ts         # Schedule persistence facade
│   ├── schedule-parser.ts        # Natural language parsing utilities
│   ├── scheduler.ts              # Generic scheduler daemon
│   ├── schedule-tool.ts          # AI tool for schedule management
│   ├── message-trigger.ts        # Slack message trigger evaluator
│   ├── cli-handler.ts            # CLI command handlers
│   └── store/                    # Storage backends
│       ├── index.ts              # Backend factory
│       ├── types.ts              # Backend interface & config types
│       └── sqlite-store.ts       # SQLite backend (OSS)
│
├── enterprise/
│   └── scheduler/
│       └── knex-store.ts         # PostgreSQL/MySQL/MSSQL backend (Enterprise)
│
└── slack/
    └── slack-output-adapter.ts   # Posts results to Slack

Key Components

Component Purpose
Scheduler Main daemon that checks for due schedules and executes them
ScheduleStore Singleton for persisting schedules to JSON
MessageTriggerEvaluator Evaluates Slack messages against on_message triggers
ScheduleOutputAdapter Interface for output destinations
SlackOutputAdapter Implements output posting for Slack
schedule-tool AI tool definition and handler

Execution Flow

  1. User creates schedule via AI tool or CLI
  2. ScheduleStore.create() persists schedule
  3. Scheduler either sets up cron job (recurring) or setTimeout (one-time)
  4. When schedule fires:
    • With workflow: Runs named workflow via StateMachineExecutionEngine
    • Without workflow: Runs reminder text through full visor pipeline
  5. SlackOutputAdapter.sendResult() posts results (unless pipeline already handled it)

Troubleshooting

Schedule Not Running

  1. Check scheduler is running: visor schedule start
  2. Verify schedule status: visor schedule list
  3. Check workflow exists in config
  4. Verify permissions allow the schedule type

Reminder Not Posting to Slack

  1. Verify the execution context includes Slack client:
    scheduler.setExecutionContext({ slack: client, slackClient: client });
  2. Check that SlackOutputAdapter is registered:
    scheduler.registerOutputAdapter(createSlackOutputAdapter(client));
  3. For simple reminders, verify the pipeline has a human-input check and AI check
  4. Check logs for [SlackOutputAdapter] Skipping post - this means the pipeline already handled output

Personal Reminders Showing in Channel

If personal reminders appear when listing in a channel, ensure:

  1. The allowedScheduleType context is being set correctly based on channel type
  2. The schedule's outputContext.target correctly identifies the channel type

Permission Denied

  1. Check permissions config matches schedule type
  2. Verify workflow matches allowed_workflows patterns
  3. Ensure workflow doesn't match denied_workflows

Timezone Issues

  1. Set explicit timezone in config: default_timezone: America/New_York
  2. User timezone is captured when schedule is created
  3. All times stored as UTC internally

API Reference

Schedule Interface

interface Schedule {
  id: string;                          // UUID v4
  creatorId: string;                   // User who created
  creatorName?: string;                // User display name (for messages)
  creatorContext?: string;             // "slack:U123", "github:user", "cli"
  timezone: string;                    // IANA timezone
  schedule: string;                    // Cron expression (empty for one-time)
  runAt?: number;                      // Unix timestamp (one-time only)
  isRecurring: boolean;
  originalExpression: string;          // Natural language input (for display)
  workflow?: string;                   // Workflow/check ID (undefined for simple reminders)
  workflowInputs?: Record<string, unknown>;  // For reminders: { text: "..." }
  outputContext?: ScheduleOutputContext;
  status: 'active' | 'paused' | 'completed' | 'failed';
  nextRunAt?: number;
  lastRunAt?: number;
  runCount: number;
  failureCount: number;
  lastError?: string;                  // Last error message if failed
  previousResponse?: string;           // AI response from last run (recurring only)
  createdAt: number;                   // Creation timestamp
}

ScheduleOutputContext

interface ScheduleOutputContext {
  type: 'slack' | 'github' | 'webhook' | 'none';
  target?: string;
  threadId?: string;
  metadata?: Record<string, unknown>;
}