scheduler

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 4, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package scheduler is a persistent, session-independent task scheduler for the coWork profile. It runs agent prompts on a recurring schedule (cron-like expressions or simple intervals) and survives app restarts via a JSON store.

Why a separate scheduler (not part of the agent loop): a scheduled task like "every weekday at 9am, compile the overnight news digest and post to IM" must fire even when no chat tab is open, and must persist across restarts. The agent loop is per-tab and transient; this scheduler is app-level and owns its own goroutine, binding to whichever controller is currently active when a task fires.

Expression format (intentionally simpler than full cron — covers office needs):

  • "every 30m" → every 30 minutes
  • "every 2h" → every 2 hours
  • "daily 09:00" → every day at 09:00 local
  • "daily 09:00 Mon-Fri" → weekdays only at 09:00 (day names: Mon-Sun)
  • "hourly" → every hour at :00
  • "at 2026-06-24 15:00" → one-shot at an absolute local time (auto-disables after firing)
  • "in 2h30m" / "in 3d" → one-shot relative offset (normalized to "at ..." before storage)
  • A 5-field cron expression is also accepted ("0 9 * * 1-5") for power users.

Relative Chinese phrases like "后天下午3点" are converted to absolute "at ..." form by ResolveRelativeTime before storage, so the UI always shows concrete instants and restarts don't drift the schedule.

The scheduler is best-effort: a missed fire (app was closed) is skipped, not backfilled — recurring office tasks don't benefit from a burst of catch-up.

Index

Constants

This section is empty.

Variables

View Source
var BuiltinTemplates = []Template{
	{
		ID:         "daily_report_reminder",
		Name:       "日报提醒",
		Category:   "reminder",
		Desc:       "每个工作日下班前提醒整理日报并发到团队群",
		Expression: "daily 18:00 Mon-Fri",
		Prompt:     "请整理今日工作日报,按「今日完成 / 明日计划 / 阻塞事项」三段式汇总,简洁列出要点。",
		OutputMode: "notify",
	},
	{
		ID:         "weekly_report_reminder",
		Name:       "周报提醒",
		Category:   "reminder",
		Desc:       "每周五下班前提醒提交周报到指定邮箱",
		Expression: "daily 17:00 Fri",
		Prompt:     "请生成本周工作周报,涵盖本周主要进展、下周计划、风险与求助,语气专业简洁。",
		OutputMode: "email",
		OutputHint: "填写收件人邮箱(可加 ;自定义主题)",
	},
	{
		ID:         "meeting_reminder",
		Name:       "会议提醒",
		Category:   "reminder",
		Desc:       "一次性提醒:某次会议开始前的通知(选一个具体时间)",
		Expression: "at 2026-06-24 14:45",
		Prompt:     "15分钟后有会议,请准备相关材料并准时参加。",
		OutputMode: "notify",
		OneShot:    true,
	},
	{
		ID:         "data_scrape",
		Name:       "定时数据抓取",
		Category:   "data",
		Desc:       "每天早上抓取关键数据并保存为本地文件",
		Expression: "daily 09:00",
		Prompt:     "打开浏览器,抓取昨日关键业务数据(销售/流量/库存),汇总为 CSV 并保存到桌面。",
		OutputMode: "file",
		OutputHint: "填写保存路径,如 C:\\Users\\me\\Desktop\\daily.csv",
	},
	{
		ID:         "system_check",
		Name:       "系统巡检",
		Category:   "ops",
		Desc:       "每小时检查系统状态,异常时通过飞书告警",
		Expression: "every 1h",
		Prompt:     "检查本机磁盘空间、内存占用、关键进程是否存活;发现异常(磁盘>90%、内存>90%、进程缺失)时简要列出问题。",
		OutputMode: "im",
		OutputHint: "填写飞书会话标识,如 feishu:oc_xxx",
	},
}

BuiltinTemplates is the static catalog. Order matters — it's how they appear in the UI menu.

View Source
var ErrIMOffline = errors.New("IM gateway offline (bot not started)")

ErrIMOffline is returned by an IMPusher when the bot gateway isn't running. deliverOutput treats it as a "skipped" delivery (with a clear reason shown to the user) rather than a hard failure — the bot may simply not be started yet. Implementations wrap/return it via errors.Is so callers don't string-match.

Functions

func Describe

func Describe(expr string) string

Describe renders an expression as a friendly Chinese phrase for UI display:

  • "every 30m" → "每 30 分钟"
  • "every 2h" → "每 2 小时"
  • "hourly" → "每小时"
  • "daily 09:00" → "每天 09:00"
  • "daily 09:00 Mon-Fri" → "工作日 09:00"
  • "daily 09:00 Sat,Sun" → "周末 09:00"
  • "daily 09:00 Mon,Wed,Fri" → "周一/三/五 09:00"
  • "at ..." → the stored timestamp (one-shot)
  • cron / unknown → the raw expression

func IsOneShot

func IsOneShot(expr string) bool

IsOneShot reports whether an expression fires once and then should auto-disable. "at ..." (and its "in ..." source, once normalized) are one-shot; everything else repeats.

func NextRunPublic

func NextRunPublic(expr string, from time.Time) time.Time

NextRunPublic is the exported wrapper around nextRun (package-internal), for callers outside scheduler (the desktop preview bridge) that need to compute a fire time without a Scheduler instance.

func NormalizeExpression

func NormalizeExpression(expr string, now time.Time) (string, error)

NormalizeExpression converts relative forms to their stored canonical form.

  • "in 2h" / "in 3d" are resolved against `now` into an absolute "at ..." so the persisted task is restart-stable (a relative offset would otherwise drift forward every load).
  • Chinese natural-language phrases ("后天下午3点", "9点50", "下周一 10:00") are resolved via ResolveRelativeTime into "at YYYY-MM-DD HH:MM". This is the fix for the "saved a natural-language task but it errored on save" bug — the preview path (PreviewSchedule) always resolved these, but Create/Update went straight to parseExpression (which only knows every/daily/at/in/cron), so a phrase the UI showed as valid became a parse error on save.
  • "at ..." and all other forms pass through.

func ResolveRelativeTime

func ResolveRelativeTime(text string, now time.Time) (time.Time, error)

ResolveRelativeTime converts a Chinese natural-language time phrase into an absolute time. It supports a compact but practical vocabulary aimed at office reminders ("后天下午3点", "下周一上午9点半", "月底 23:59"). The returned time is in the local timezone.

Recognized vocabulary:

  • Date words (mutually exclusive — pick the first that matches): 今天 / 今日 / 明天 / 明日 / 后天 / 大后天 / 大前天 下周X / 下周星期X / 本周X (X = 一/二/.../日 or 1..7) 周X / 星期X / 礼拜X (this week's day X) N号 / N日 / N月N日 / N月N号 (absolute month/day in the current year) 月底 (last day of current month) YYYY年MM月DD日 (fully absolute)

  • Time words: 上午N点 / 早上N点 / N点 (hour N in the morning; 0<=N<=11) 中午12点 / 中午N点 (noon hour) 下午N点 / 傍晚N点 / 晚上N点 / 夜里N点 (hour N+12 for 1<=N<=11, or N for 12) N点半 / N点30分 (N:30) N点M分 (N:M) HH:MM (24-hour absolute)

Date and time may appear in either order; missing time defaults to 00:00. Missing date defaults to today (but the result must be in the future; if the same-day resolution yields a past instant, the date advances to tomorrow for pure-time inputs like "下午3点" — matching how people read such phrases).

Anything that doesn't match returns an error; callers (the UI preview and the Create path) fall back to the literal expression.

Types

type AccountProber

type AccountProber interface {
	Probe(account string) error
}

AccountProber probes the connectivity of a named mail account before a scheduled task spends tokens trying to send through it. The account name is the same string a task carries in OutputAccount (resolved by the prober implementation to its IMAP/SMTP credentials). Returning nil means "good to go"; a non-nil error skips the delivery with a friendly reason instead of burning a half-token agent run on an expired credential (the 139 90-day authorization-code case this exists for).

type EmailSender

type EmailSender interface {
	Send(ctx context.Context, account, to, subject, body string) error
}

EmailSender delivers a scheduled-task result via SMTP. account selects the named mailbox to send from (empty = default); to is the recipient (or "to;subject"); the desktop app supplies one backed by the same multi-account SMTP config as the email_send tool. nil means email output mode degrades to store-only.

type IMPusher

type IMPusher interface {
	Push(ctx context.Context, dest, text string) error
}

IMPusher delivers a scheduled-task result to an IM channel. The desktop app supplies one backed by the bot gateway (gw.Push). When nil, IM output mode is a no-op (the result is still stored on the task for schedule_list).

type MissedReminder

type MissedReminder struct {
	Name string
	Body string // the prompt text (or a generic note if empty)
}

MissedReminder describes a one-shot task that was due while the app was down. DrainMissedReminders returns and clears the pending list. The desktop layer fires a catch-up notification for each (the task itself is already disabled).

type Notifier

type Notifier interface {
	Notify(name, result string)
}

Notifier surfaces a run result to the user in-app (desktop toast / event). The desktop app supplies one backed by Wails runtime.EventsEmit; nil means notify output mode degrades to store-only.

type RunRecord

type RunRecord struct {
	TaskID     string    `json:"task_id"`
	Name       string    `json:"name"`
	At         time.Time `json:"at"`
	Status     string    `json:"status"`      // "ok" | "error" | "skipped"
	Result     string    `json:"result"`      // truncated
	OutputMode string    `json:"output_mode"` // echoed from the task at fire time
}

RunRecord is one entry in the per-scheduler run history ring buffer. Kept in-memory + persisted to a sidecar JSON so the UI can show recent runs even after a restart.

type Runner

type Runner interface {
	Run(ctx context.Context, profile, prompt string) (string, error)
}

Runner is the bridge to a controller: the scheduler calls Run with the task's prompt and gets back a result string. The desktop app supplies an implementation that targets the active cowork controller.

type ScheduledTask

type ScheduledTask struct {
	ID         string    `json:"id"`
	Name       string    `json:"name"`
	Expression string    `json:"expression"` // "every 30m" | "daily 09:00" | "at ..." | cron
	Prompt     string    `json:"prompt"`
	Profile    string    `json:"profile,omitempty"` // empty = cowork
	Enabled    bool      `json:"enabled"`
	OneShot    bool      `json:"one_shot,omitempty"` // at/in; auto-disables after firing
	LastRun    time.Time `json:"last_run,omitempty"`
	NextRun    time.Time `json:"next_run,omitempty"`
	RunCount   int       `json:"run_count"`
	LastResult string    `json:"last_result,omitempty"` // truncated run output / error
	OutputMode string    `json:"output_mode,omitempty"` // "" | "im" | "file" | "email" | "notify"
	OutputDest string    `json:"output_dest,omitempty"` // IM channel / file path / email "to" / (notify: unused)
	// OutputAccount selects the named mailbox used for "email" delivery (empty
	// = the default account). Lets one task send from a work mailbox and another
	// from a personal one.
	OutputAccount string `json:"output_account,omitempty"`
	// UI-facing display attributes. OutputDir is an optional folder that
	// concentrates a task's file artifacts (CSV/report/docs) instead of the
	// shared workspace root; the agent prompt may reference it. Color/Location
	// render the task on the calendar grid. These were previously sent by the
	// UI form but dropped because they had no backing struct field.
	OutputDir      string `json:"output_dir,omitempty"`
	Color          string `json:"color,omitempty"`
	Location       string `json:"location,omitempty"`
	LastDeliverErr string `json:"last_deliver_err,omitempty"` // "" if last delivery succeeded / was skipped
	// Plain marks a task whose Prompt is a plain reminder to be surfaced verbatim
	// (toast/IM/email body) WITHOUT running the agent. Set explicitly by the UI
	// ("纯提醒" toggle) — we do NOT guess this from prompt text, because no
	// heuristic can reliably tell "周报" (AI task, no verb) from "下班打卡" (plain
	// reminder, no verb). Plain=false (default) always runs the agent.
	Plain         bool      `json:"plain,omitempty"`
	LastDeliverAt time.Time `json:"last_deliver_at,omitempty"` // when the most recent delivery was attempted
}

ScheduledTask is one recurring prompt. Prompt is the agent input fired on each run; Profile selects which product profile's controller runs it (default cowork). OutputMode/OutputDest route the result. Enabled=false pauses without deleting. OneShot tasks (Expression "at ...") auto-disable after their single fire and remain in the list for history.

type Scheduler

type Scheduler struct {
	// contains filtered or unexported fields
}

Scheduler owns the task store and the firing goroutine. Create once per app (desktop), call Start to begin firing, SetRunner to bind a controller bridge.

func New

func New(storePath string) *Scheduler

New creates a scheduler backed by storePath. The history sidecar is written next to storePath (storePath with ".history" suffix). Start must be called to fire.

func (*Scheduler) Create

func (s *Scheduler) Create(t ScheduledTask) (ScheduledTask, error)

Create adds a task, validates its expression, persists, and returns it. "in X" expressions are normalized to absolute "at ..." against now so the task is restart-stable. OneShot is inferred from the resulting expression.

func (*Scheduler) Delete

func (s *Scheduler) Delete(id string) bool

Delete removes a task by id.

func (*Scheduler) DrainMissedReminders

func (s *Scheduler) DrainMissedReminders() []MissedReminder

DrainMissedReminders returns and clears the one-shot tasks that were due while the app was down (detected during Load). The desktop layer calls this after binding the notifier, firing a catch-up notification for each so the user isn't left believing a reminder they set was silently lost.

func (*Scheduler) Get

func (s *Scheduler) Get(id string) (ScheduledTask, bool)

Get returns a single task by ID (zero value + false if not found).

func (*Scheduler) History

func (s *Scheduler) History(taskID string) []RunRecord

History returns recent run records, newest first. If taskID is non-empty, only records for that task are returned. Limited to the in-memory ring buffer.

func (*Scheduler) List

func (s *Scheduler) List(enabledOnly bool) []ScheduledTask

List returns all tasks (optionally enabled-only).

func (*Scheduler) Load

func (s *Scheduler) Load() error

Load reads persisted tasks. Called by New-equivalent flows; also re-read after external edits. Safe to call before Start.

func (*Scheduler) RunNow

func (s *Scheduler) RunNow(id string) (string, error)

RunNow fires a task immediately, regardless of its schedule. Delivery runs as usual and a history record is appended. The task's schedule is NOT advanced (it still fires at its next scheduled time). Returns the truncated result.

func (*Scheduler) SetAccountProber

func (s *Scheduler) SetAccountProber(p AccountProber)

SetAccountProber binds the account connectivity prober used before email delivery. Safe to call before Start; nil (the default) skips probing and preserves the prior fire-and-let-SMTP-fail behavior.

func (*Scheduler) SetEmailSender

func (s *Scheduler) SetEmailSender(e EmailSender)

SetEmailSender binds the SMTP bridge for tasks with OutputMode="email". Nil = email output is a no-op (result still stored on the task).

func (*Scheduler) SetIMPusher

func (s *Scheduler) SetIMPusher(p IMPusher)

SetIMPusher binds the IM delivery bridge for tasks with OutputMode="im". Nil = IM output is a no-op (result still stored on the task).

func (*Scheduler) SetLogger

func (s *Scheduler) SetLogger(logf func(format string, args ...any))

SetLogger installs a diagnostic logger (e.g. slog); default is silent.

func (*Scheduler) SetNotifier

func (s *Scheduler) SetNotifier(n Notifier)

SetNotifier binds the in-app notification bridge for tasks with OutputMode="notify". Nil = notifications are a no-op.

func (*Scheduler) SetRunner

func (s *Scheduler) SetRunner(r Runner)

SetRunner binds the controller bridge. Required before tasks can fire; if nil at fire time the run is skipped with a "no runner" result.

func (*Scheduler) Start

func (s *Scheduler) Start()

Start launches the precise-timer scheduling loop. Idempotent (a second call while running is a no-op); can restart after Stop. Unlike a fixed-interval poller, this uses a single time.AfterFunc timer that fires at the EXACT NextRun of the nearest due task — second-level precision with zero CPU between fires (the goroutine is parked until the OS wakes it). Re-armed automatically after each fire and on any Create/Update/Delete/Load.

func (*Scheduler) Stop

func (s *Scheduler) Stop()

Stop halts the scheduling timer.

func (*Scheduler) Update

func (s *Scheduler) Update(id string, mut func(*ScheduledTask)) (ScheduledTask, error)

Update mutates a task's mutable fields (name/expression/prompt/enabled/...). "in ..." expressions in the mutation are normalized to absolute "at ...".

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store persists tasks to a JSON file so they survive restarts. The file is rewritten atomically on every mutation.

type Template

type Template struct {
	ID         string `json:"id"`
	Name       string `json:"name"`
	Category   string `json:"category"`   // "reminder" | "data" | "ops"
	Desc       string `json:"desc"`       // short human description
	Expression string `json:"expression"` // default expression (may be a placeholder the UI fills)
	Prompt     string `json:"prompt"`     // default prompt body
	OutputMode string `json:"output_mode"`
	OutputHint string `json:"output_hint"` // UI hint for OutputDest (e.g. "填写收件人邮箱")
	OneShot    bool   `json:"one_shot,omitempty"`
}

Template is a predefined, one-click scheduled-task recipe. The UI lists these in the "模板" menu; selecting one pre-fills the create form (name/expression/ prompt/output mode). The user then customizes (e.g. picks a concrete time for the meeting reminder) and saves.

Templates intentionally avoid jargon — they map directly to common office automations a non-technical user would want, mirroring the WorkBuddy "添加自 动化任务" pattern of trigger → action → delivery.

func Templates

func Templates() []Template

Templates returns a copy of the builtin catalog (so callers can't mutate it).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL