| 1 | // Heartbeat task engine — scheduled AI prompts that create or update topics. |
| 2 | // |
| 3 | // Each task is a prompt submitted to a dedicated topic on a schedule. |
| 4 | // The config file under the Reasonix user state directory is human- and |
| 5 | // AI-editable; the engine runs the schedule in a background goroutine and |
| 6 | // exposes Wails bindings on App for the frontend panel. |
| 7 | // |
| 8 | // Design goal: minimal upstream intrusion — one file, zero changes to existing |
| 9 | // Go code (App field + startup line + bindings are the only touch points). |
| 10 | |
| 11 | package main |
| 12 | |
| 13 | import ( |
| 14 | "encoding/json" |
| 15 | "log" |
| 16 | "math/rand" |
| 17 | "os" |
| 18 | "path/filepath" |
| 19 | "strings" |
| 20 | "sync" |
| 21 | "time" |
| 22 | |
| 23 | "reasonix/internal/config" |
| 24 | "reasonix/internal/control" |
| 25 | "reasonix/internal/event" |
| 26 | "reasonix/internal/secrets" |
| 27 | ) |
| 28 | |
| 29 | // ── Data model ────────────────────────────────────────────────────────────── |
| 30 | |
| 31 | // HeartbeatTask defines a single scheduled prompt. |
| 32 | type HeartbeatTask struct { |
| 33 | ID string `json:"id"` |
| 34 | Title string `json:"title"` // user-visible label |
| 35 | Prompt string `json:"prompt"` // the prompt to submit |
| 36 | Interval string `json:"interval"` // e.g. "5m", "1h", "30s" |
| 37 | Enabled bool `json:"enabled"` |
| 38 | Scope string `json:"scope,omitempty"` // "global" or "project" |
| 39 | WorkspaceRoot string `json:"workspaceRoot,omitempty"` // project root path when scope="project" |
| 40 | TopicID string `json:"topicId,omitempty"` // created topic, reused on re-run |
| 41 | LastRunAt int64 `json:"lastRunAt,omitempty"` // unix millis |
| 42 | NewConversationEachRun bool `json:"newConversationEachRun,omitempty"` // true = create new topic every run |
| 43 | CreatedAt int64 `json:"createdAt,omitempty"` |
| 44 | ApprovalMode string `json:"approvalMode"` // "ask" | "auto" | "yolo"; empty defaults to "yolo" |
| 45 | TimeWindowStart string `json:"timeWindowStart,omitempty"` // "HH:MM" — interval tasks only run after this time (inclusive) |
| 46 | TimeWindowEnd string `json:"timeWindowEnd,omitempty"` // "HH:MM" — interval tasks only run before this time (exclusive) |
| 47 | NotifyChannels *bool `json:"notifyChannels,omitempty"` // true = push to bot channels; nil/false = skip |
| 48 | } |
| 49 | |
| 50 | // heartbeatConfig is the on-disk format. |
| 51 | type heartbeatConfig struct { |
| 52 | Tasks []HeartbeatTask `json:"tasks"` |
| 53 | } |
| 54 | |
| 55 | // ── Engine ────────────────────────────────────────────────────────────────── |
| 56 | |
| 57 | // HeartbeatEngine runs scheduled task execution in a background goroutine. |
| 58 | // It is owned by App and started during App.startup. |
| 59 | type HeartbeatEngine struct { |
| 60 | mu sync.Mutex |
| 61 | tasks []HeartbeatTask |
| 62 | cfgMod time.Time // config-file mtime as of the engine's last read/write (external-edit probe) |
| 63 | pendingTopics map[string]heartbeatPendingTopic // in-memory retry/in-flight safety for NewConversationEachRun |
| 64 | done chan struct{} |
| 65 | running bool |
| 66 | app *App // back-reference for topic creation, tab routing, and prompt submission |
| 67 | } |
| 68 | |
| 69 | type heartbeatPendingTopic struct { |
| 70 | TopicID string |
| 71 | Submitted bool |
| 72 | } |
| 73 | |
| 74 | func newHeartbeatEngine(app *App) *HeartbeatEngine { |
| 75 | return &HeartbeatEngine{ |
| 76 | app: app, |
| 77 | done: make(chan struct{}), |
| 78 | pendingTopics: make(map[string]heartbeatPendingTopic), |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // configPath returns the JSON file path. |
| 83 | func (e *HeartbeatEngine) configPath() string { |
| 84 | dir := config.MemoryUserDir() |
| 85 | if dir == "" { |
| 86 | dir = "." |
| 87 | } |
| 88 | return filepath.Join(dir, "heartbeat-tasks.json") |
| 89 | } |
| 90 | |
| 91 | // loadTasks reads tasks from disk. |
| 92 | func (e *HeartbeatEngine) loadTasks() []HeartbeatTask { |
| 93 | b, err := readFileUTF8(e.configPath()) |
| 94 | if err != nil { |
| 95 | return nil |
| 96 | } |
| 97 | var cfg heartbeatConfig |
| 98 | if err := json.Unmarshal(b, &cfg); err != nil { |
| 99 | log.Printf("[heartbeat] invalid config: %v", err) |
| 100 | return nil |
| 101 | } |
| 102 | return cfg.Tasks |
| 103 | } |
| 104 | |
| 105 | // noteConfigModLocked records the config file's current mtime so the tick |
| 106 | // external-edit probe does not re-read a file the engine itself just wrote. |
| 107 | func (e *HeartbeatEngine) noteConfigModLocked() { |
| 108 | if info, err := os.Stat(e.configPath()); err == nil { |
| 109 | e.cfgMod = info.ModTime() |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // adoptExternalEditsLocked re-reads the config when its mtime moved since the |
| 114 | // engine last touched the file: heartbeat-tasks.json is documented as human- |
| 115 | // and AI-editable, so an external edit should be scheduled by the next tick |
| 116 | // rather than waiting for a manual Refresh or an app restart. |
| 117 | func (e *HeartbeatEngine) adoptExternalEditsLocked() { |
| 118 | info, err := os.Stat(e.configPath()) |
| 119 | if err != nil || info.ModTime().Equal(e.cfgMod) { |
| 120 | return |
| 121 | } |
| 122 | e.cfgMod = info.ModTime() |
| 123 | e.tasks = e.loadTasks() |
| 124 | e.prunePendingTopicsLocked(e.tasks) |
| 125 | } |
| 126 | |
| 127 | // saveTasks writes tasks to disk atomically. |
| 128 | func (e *HeartbeatEngine) saveTasks(tasks []HeartbeatTask) error { |
| 129 | if tasks == nil { |
| 130 | tasks = []HeartbeatTask{} |
| 131 | } |
| 132 | cfg := heartbeatConfig{Tasks: tasks} |
| 133 | b, err := json.MarshalIndent(cfg, "", " ") |
| 134 | if err != nil { |
| 135 | return err |
| 136 | } |
| 137 | path := e.configPath() |
| 138 | // Ensure the parent directory exists before writing. |
| 139 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 140 | return err |
| 141 | } |
| 142 | tmp := path + ".tmp" |
| 143 | if err := os.WriteFile(tmp, b, 0o644); err != nil { |
| 144 | return err |
| 145 | } |
| 146 | return os.Rename(tmp, path) |
| 147 | } |
| 148 | |
| 149 | // Start launches the scheduler goroutine. |
| 150 | func (e *HeartbeatEngine) Start() { |
| 151 | e.mu.Lock() |
| 152 | defer e.mu.Unlock() |
| 153 | if e.running { |
| 154 | return |
| 155 | } |
| 156 | e.tasks = e.loadTasks() |
| 157 | e.noteConfigModLocked() |
| 158 | e.running = true |
| 159 | go e.loop() |
| 160 | log.Printf("[heartbeat] engine started (%d tasks)", len(e.tasks)) |
| 161 | } |
| 162 | |
| 163 | // Stop signals the scheduler goroutine to exit. |
| 164 | func (e *HeartbeatEngine) Stop() { |
| 165 | e.mu.Lock() |
| 166 | defer e.mu.Unlock() |
| 167 | if !e.running { |
| 168 | return |
| 169 | } |
| 170 | e.running = false |
| 171 | close(e.done) |
| 172 | } |
| 173 | |
| 174 | // loop is the main scheduler loop — tick every 30s and check each enabled task. |
| 175 | func (e *HeartbeatEngine) loop() { |
| 176 | ticker := time.NewTicker(30 * time.Second) |
| 177 | defer ticker.Stop() |
| 178 | for { |
| 179 | select { |
| 180 | case <-e.done: |
| 181 | return |
| 182 | case <-ticker.C: |
| 183 | e.tick() |
| 184 | } |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | // tick checks every enabled task and runs those whose interval has elapsed. |
| 189 | // It first adopts any external edit to the config file (human/AI-editable), |
| 190 | // then merges results (topicId, lastRunAt) rather than replacing the full |
| 191 | // list, so concurrent HeartbeatSaveTasks edits are not lost. |
| 192 | func (e *HeartbeatEngine) tick() { |
| 193 | e.mu.Lock() |
| 194 | e.adoptExternalEditsLocked() |
| 195 | tasks := append([]HeartbeatTask(nil), e.tasks...) |
| 196 | e.mu.Unlock() |
| 197 | |
| 198 | now := time.Now() |
| 199 | updates := make(map[string]HeartbeatTask) |
| 200 | for i, t := range tasks { |
| 201 | if !t.Enabled { |
| 202 | continue |
| 203 | } |
| 204 | if !heartbeatTaskDueAt(t, now) { |
| 205 | continue |
| 206 | } |
| 207 | // Run this task |
| 208 | tasks[i] = e.executeTask(t) |
| 209 | updates[t.ID] = tasks[i] |
| 210 | } |
| 211 | |
| 212 | e.mu.Lock() |
| 213 | e.mergeRunUpdatesLocked(updates) |
| 214 | e.mu.Unlock() |
| 215 | } |
| 216 | |
| 217 | // normalizeHeartbeatApprovalMode returns a valid approval mode for the task. |
| 218 | // Empty or unknown values default to "yolo" so that scheduled tasks run |
| 219 | // without interrupting the user for permission prompts. |
| 220 | func normalizeHeartbeatApprovalMode(mode string) string { |
| 221 | normalized := strings.ToLower(strings.TrimSpace(mode)) |
| 222 | switch normalized { |
| 223 | case "ask", "auto", "yolo": |
| 224 | return normalized |
| 225 | default: |
| 226 | return "yolo" |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | type heartbeatRuntimeStatus interface { |
| 231 | RuntimeStatus() control.RuntimeStatus |
| 232 | } |
| 233 | |
| 234 | func heartbeatControllerBusy(ctrl heartbeatRuntimeStatus) bool { |
| 235 | status := ctrl.RuntimeStatus() |
| 236 | return status.Running || status.PendingPrompt |
| 237 | } |
| 238 | |
| 239 | // executeTask runs one heartbeat: creates/opens topic, submits prompt. |
| 240 | // Returns the updated task (topicId and LastRunAt may change). |
| 241 | // On controller failure the task is returned WITHOUT updating LastRunAt, |
| 242 | // so it will be retried on the next tick. |
| 243 | func (e *HeartbeatEngine) executeTask(t HeartbeatTask) HeartbeatTask { |
| 244 | title := "Heartbeat: " + t.Title |
| 245 | scope := t.Scope |
| 246 | workspaceRoot := t.WorkspaceRoot |
| 247 | if scope == "" { |
| 248 | scope = "global" |
| 249 | } |
| 250 | |
| 251 | // Determine which topic to use. |
| 252 | // |
| 253 | // For NewConversationEachRun: |
| 254 | // - Reuse a pending topic from a failed pre-submit attempt. |
| 255 | // - Re-check a submitted topic until its controller is idle, so a long |
| 256 | // previous run cannot overlap with the next scheduled fresh topic. |
| 257 | // - Once the submitted topic is idle and due again, clear it and create a |
| 258 | // fresh topic. |
| 259 | // - topicId is always updated to the latest conversation so the task list |
| 260 | // always points to the most recent session regardless of mode switch. |
| 261 | // |
| 262 | // For the legacy mode: |
| 263 | // - Reuse the persisted topicID if available; create one on first run. |
| 264 | var topicID string |
| 265 | var pendingSubmitted bool |
| 266 | if t.NewConversationEachRun { |
| 267 | e.mu.Lock() |
| 268 | pending := e.pendingTopics[t.ID] |
| 269 | e.mu.Unlock() |
| 270 | topicID = pending.TopicID |
| 271 | pendingSubmitted = pending.Submitted |
| 272 | if topicID == "" { |
| 273 | // No pending topic — create a fresh one. |
| 274 | meta, err := e.app.CreateTopic(scope, workspaceRoot, title) |
| 275 | if err != nil { |
| 276 | log.Printf("[heartbeat] CreateTopic(%q): %v", t.Title, err) |
| 277 | t.LastRunAt = time.Now().UnixMilli() |
| 278 | return t |
| 279 | } |
| 280 | topicID = meta.ID |
| 281 | t.TopicID = topicID // always persist the latest topic |
| 282 | // Save in-memory for retry safety (NOT persisted to disk). |
| 283 | e.mu.Lock() |
| 284 | if e.pendingTopics == nil { |
| 285 | e.pendingTopics = make(map[string]heartbeatPendingTopic) |
| 286 | } |
| 287 | e.pendingTopics[t.ID] = heartbeatPendingTopic{TopicID: topicID} |
| 288 | e.mu.Unlock() |
| 289 | } |
| 290 | } else { |
| 291 | topicID = t.TopicID |
| 292 | if topicID == "" { |
| 293 | meta, err := e.app.CreateTopic(scope, workspaceRoot, title) |
| 294 | if err != nil { |
| 295 | log.Printf("[heartbeat] CreateTopic(%q): %v", t.Title, err) |
| 296 | t.LastRunAt = time.Now().UnixMilli() |
| 297 | return t |
| 298 | } |
| 299 | topicID = meta.ID |
| 300 | t.TopicID = topicID |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | // Open the tab for the topic (creates one if needed) without changing the |
| 305 | // user's active tab or active workspace pointer. |
| 306 | var tabMeta TabMeta |
| 307 | var err error |
| 308 | if scope == "project" && workspaceRoot != "" { |
| 309 | tabMeta, err = e.app.openProjectTabInactive(workspaceRoot, topicID) |
| 310 | } else { |
| 311 | tabMeta, err = e.app.openGlobalTabInactive(topicID) |
| 312 | } |
| 313 | if err != nil { |
| 314 | log.Printf("[heartbeat] OpenTab(%q): %s", t.Title, secrets.RedactError(err)) |
| 315 | t.LastRunAt = time.Now().UnixMilli() |
| 316 | return t |
| 317 | } |
| 318 | |
| 319 | // Wait for the tab's controller to be built (it's started |
| 320 | // asynchronously in a goroutine by openTopicTab). |
| 321 | var ctrl heartbeatRuntimeStatus |
| 322 | for i := 0; i < 40; i++ { |
| 323 | if candidate := e.app.ctrlByTabID(tabMeta.ID); candidate != nil { |
| 324 | ctrl = candidate |
| 325 | break |
| 326 | } |
| 327 | time.Sleep(250 * time.Millisecond) |
| 328 | } |
| 329 | if ctrl == nil { |
| 330 | log.Printf("[heartbeat] controller not ready for %q, skipping", t.Title) |
| 331 | return t // don't update LastRunAt — retry next tick |
| 332 | } |
| 333 | if heartbeatControllerBusy(ctrl) { |
| 334 | log.Printf("[heartbeat] controller busy for %q, skipping", t.Title) |
| 335 | return t // don't change approval mode for an existing turn — retry next tick |
| 336 | } |
| 337 | if t.NewConversationEachRun && pendingSubmitted { |
| 338 | e.mu.Lock() |
| 339 | if pending := e.pendingTopics[t.ID]; pending.TopicID == topicID && pending.Submitted { |
| 340 | delete(e.pendingTopics, t.ID) |
| 341 | } |
| 342 | e.mu.Unlock() |
| 343 | return e.executeTask(t) |
| 344 | } |
| 345 | |
| 346 | // Set the task's approval mode only after confirming the controller is idle. |
| 347 | // SetToolApprovalModeForTab may drain pending approvals for auto/yolo modes, |
| 348 | // so applying it to a busy reused topic would accidentally approve a previous |
| 349 | // turn instead of preparing this heartbeat prompt. |
| 350 | mode := normalizeHeartbeatApprovalMode(t.ApprovalMode) |
| 351 | t.ApprovalMode = mode |
| 352 | e.app.SetToolApprovalModeForTab(tabMeta.ID, mode) |
| 353 | |
| 354 | // Attach bot event forwarding if the bot runtime is active and has |
| 355 | // session-mapped targets. The forwarder is set on the tab's event sink |
| 356 | // so AI output events are streamed to connected bot channels in |
| 357 | // real-time alongside the desktop UI. |
| 358 | var botForwarder event.Sink |
| 359 | if t.NotifyChannels != nil && *t.NotifyChannels { |
| 360 | botForwarder = e.newBotForwarder(tabMeta.ID) |
| 361 | } |
| 362 | |
| 363 | // Submit as a plain user turn so scheduled prompts cannot invoke desktop |
| 364 | // shell or slash-command handlers such as "!cmd", "/clear", or "/compact". |
| 365 | if !e.app.submitUserTurnToTabWithSink(tabMeta.ID, t.Prompt, botForwarder) { |
| 366 | log.Printf("[heartbeat] submit skipped for %q", t.Title) |
| 367 | return t |
| 368 | } |
| 369 | |
| 370 | // After a successful submit, keep the topic as an in-flight guard. The next |
| 371 | // due run will busy-check this controller before creating a fresh topic. |
| 372 | if t.NewConversationEachRun { |
| 373 | e.mu.Lock() |
| 374 | if e.pendingTopics == nil { |
| 375 | e.pendingTopics = make(map[string]heartbeatPendingTopic) |
| 376 | } |
| 377 | e.pendingTopics[t.ID] = heartbeatPendingTopic{TopicID: topicID, Submitted: true} |
| 378 | e.mu.Unlock() |
| 379 | } |
| 380 | |
| 381 | t.LastRunAt = time.Now().UnixMilli() |
| 382 | if t.CreatedAt == 0 { |
| 383 | t.CreatedAt = t.LastRunAt |
| 384 | } |
| 385 | return t |
| 386 | } |
| 387 | |
| 388 | // ListTasks returns a copy of the current tasks (in-memory). |
| 389 | func (e *HeartbeatEngine) ListTasks() []HeartbeatTask { |
| 390 | e.mu.Lock() |
| 391 | defer e.mu.Unlock() |
| 392 | out := make([]HeartbeatTask, len(e.tasks)) |
| 393 | copy(out, e.tasks) |
| 394 | return out |
| 395 | } |
| 396 | |
| 397 | // ReloadTasks reloads the task list from disk and replaces the in-memory copy. |
| 398 | func (e *HeartbeatEngine) ReloadTasks() []HeartbeatTask { |
| 399 | e.mu.Lock() |
| 400 | defer e.mu.Unlock() |
| 401 | e.tasks = e.loadTasks() |
| 402 | e.noteConfigModLocked() |
| 403 | e.prunePendingTopicsLocked(e.tasks) |
| 404 | out := make([]HeartbeatTask, len(e.tasks)) |
| 405 | copy(out, e.tasks) |
| 406 | return out |
| 407 | } |
| 408 | |
| 409 | // ReplaceTasks atomically replaces the task list and persists it. |
| 410 | func (e *HeartbeatEngine) ReplaceTasks(tasks []HeartbeatTask) error { |
| 411 | e.mu.Lock() |
| 412 | defer e.mu.Unlock() |
| 413 | e.tasks = tasks |
| 414 | e.prunePendingTopicsLocked(tasks) |
| 415 | err := e.saveTasks(tasks) |
| 416 | e.noteConfigModLocked() |
| 417 | return err |
| 418 | } |
| 419 | |
| 420 | func (e *HeartbeatEngine) prunePendingTopicsLocked(tasks []HeartbeatTask) { |
| 421 | if len(e.pendingTopics) == 0 { |
| 422 | return |
| 423 | } |
| 424 | keep := make(map[string]bool, len(tasks)) |
| 425 | for _, task := range tasks { |
| 426 | if task.NewConversationEachRun { |
| 427 | keep[task.ID] = true |
| 428 | } |
| 429 | } |
| 430 | for id := range e.pendingTopics { |
| 431 | if !keep[id] { |
| 432 | delete(e.pendingTopics, id) |
| 433 | } |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | // TriggerNow runs a single task immediately by ID. |
| 438 | func (e *HeartbeatEngine) TriggerNow(id string) { |
| 439 | e.mu.Lock() |
| 440 | tasks := append([]HeartbeatTask(nil), e.tasks...) |
| 441 | e.mu.Unlock() |
| 442 | updates := make(map[string]HeartbeatTask, 1) |
| 443 | for i, t := range tasks { |
| 444 | if t.ID == id { |
| 445 | tasks[i] = e.executeTask(t) |
| 446 | updates[id] = tasks[i] |
| 447 | break |
| 448 | } |
| 449 | } |
| 450 | if len(updates) == 0 { |
| 451 | return |
| 452 | } |
| 453 | e.mu.Lock() |
| 454 | e.mergeRunUpdatesLocked(updates) |
| 455 | e.mu.Unlock() |
| 456 | } |
| 457 | |
| 458 | func (e *HeartbeatEngine) mergeRunUpdatesLocked(updates map[string]HeartbeatTask) { |
| 459 | if len(updates) == 0 { |
| 460 | return |
| 461 | } |
| 462 | // Rebase onto the on-disk list before the full-list save: the config file |
| 463 | // is documented as human- and AI-editable, so an external edit may have |
| 464 | // landed after the in-memory snapshot this tick ran from. The engine owns |
| 465 | // only the run-state fields (TopicID, LastRunAt, CreatedAt backfill); |
| 466 | // task definitions added, edited, or deleted externally are adopted from |
| 467 | // disk, so the save below can never silently roll an external edit back. |
| 468 | tasks := e.loadTasks() |
| 469 | if tasks == nil { |
| 470 | // Missing or unreadable file: fall back to the in-memory list so the |
| 471 | // run state still lands (the historical behavior). |
| 472 | tasks = append([]HeartbeatTask(nil), e.tasks...) |
| 473 | } |
| 474 | for i := range tasks { |
| 475 | update, ok := updates[tasks[i].ID] |
| 476 | if !ok { |
| 477 | continue |
| 478 | } |
| 479 | if update.TopicID != "" { |
| 480 | tasks[i].TopicID = update.TopicID |
| 481 | } |
| 482 | if update.LastRunAt != 0 { |
| 483 | tasks[i].LastRunAt = update.LastRunAt |
| 484 | } |
| 485 | if tasks[i].CreatedAt == 0 && update.CreatedAt != 0 { |
| 486 | tasks[i].CreatedAt = update.CreatedAt |
| 487 | } |
| 488 | } |
| 489 | e.tasks = tasks |
| 490 | e.prunePendingTopicsLocked(tasks) |
| 491 | _ = e.saveTasks(tasks) |
| 492 | e.noteConfigModLocked() |
| 493 | } |
| 494 | |
| 495 | // parseInterval converts a string like "5m", "1h", "30s" to time.Duration. |
| 496 | // Suffix after '|' is stripped (e.g. "24h|daily@09:00" -> "24h"). |
| 497 | // Empty or invalid strings return 0, nil (task will be skipped). |
| 498 | func parseInterval(s string) (time.Duration, error) { |
| 499 | if idx := strings.Index(s, "|"); idx >= 0 { |
| 500 | s = s[:idx] |
| 501 | } |
| 502 | if len(s) == 0 { |
| 503 | return 0, nil |
| 504 | } |
| 505 | // Support common suffixed intervals |
| 506 | switch s[len(s)-1] { |
| 507 | case 's', 'm', 'h': |
| 508 | return time.ParseDuration(s) |
| 509 | default: |
| 510 | // Try "Xm" as default assumption |
| 511 | return time.ParseDuration(s + "m") |
| 512 | } |
| 513 | } |
| 514 | |
| 515 | func heartbeatTaskDueAt(t HeartbeatTask, now time.Time) bool { |
| 516 | if scheduled, ok := previousHeartbeatScheduleAt(t, now); ok { |
| 517 | if t.CreatedAt != 0 && scheduled.Before(time.UnixMilli(t.CreatedAt)) { |
| 518 | return false |
| 519 | } |
| 520 | if t.LastRunAt != 0 && !time.UnixMilli(t.LastRunAt).Before(scheduled) { |
| 521 | return false |
| 522 | } |
| 523 | return !scheduled.After(now) |
| 524 | } |
| 525 | |
| 526 | d, err := parseInterval(t.Interval) |
| 527 | if err != nil || d <= 0 { |
| 528 | return false |
| 529 | } |
| 530 | baseMillis := t.LastRunAt |
| 531 | if baseMillis == 0 { |
| 532 | baseMillis = t.CreatedAt |
| 533 | } |
| 534 | hasTimeWindow := t.TimeWindowStart != "" || t.TimeWindowEnd != "" |
| 535 | if baseMillis == 0 { |
| 536 | if hasTimeWindow { |
| 537 | return heartbeatWithinTimeWindow(t, now) |
| 538 | } |
| 539 | return true |
| 540 | } |
| 541 | if now.Sub(time.UnixMilli(baseMillis)) < d { |
| 542 | return false |
| 543 | } |
| 544 | |
| 545 | // For interval-based tasks with a time window, check if current time |
| 546 | // falls within the configured window. If outside, defer until the next |
| 547 | // tick that falls within the window. |
| 548 | if hasTimeWindow { |
| 549 | return heartbeatWithinTimeWindow(t, now) |
| 550 | } |
| 551 | |
| 552 | return true |
| 553 | } |
| 554 | |
| 555 | // heartbeatWithinTimeWindow returns true when now falls within the task's |
| 556 | // configured time window. If the window is empty it returns true. |
| 557 | // Format: "HH:MM" in 24-hour clock; start inclusive, end exclusive. |
| 558 | func heartbeatWithinTimeWindow(t HeartbeatTask, now time.Time) bool { |
| 559 | startH, startM, startOK := parseHeartbeatClock(t.TimeWindowStart) |
| 560 | endH, endM, endOK := parseHeartbeatClock(t.TimeWindowEnd) |
| 561 | |
| 562 | if !startOK && !endOK { |
| 563 | return true // no window configured |
| 564 | } |
| 565 | |
| 566 | minutes := now.Hour()*60 + now.Minute() |
| 567 | |
| 568 | // If only start is set: allow from start to end of day |
| 569 | if startOK && !endOK { |
| 570 | return minutes >= startH*60+startM |
| 571 | } |
| 572 | |
| 573 | // If only end is set: allow from midnight to end |
| 574 | if !startOK && endOK { |
| 575 | return minutes < endH*60+endM |
| 576 | } |
| 577 | |
| 578 | startMin := startH*60 + startM |
| 579 | endMin := endH*60 + endM |
| 580 | |
| 581 | if startMin < endMin { |
| 582 | // Normal window: 09:00-17:00 |
| 583 | return minutes >= startMin && minutes < endMin |
| 584 | } |
| 585 | // Cross-midnight window: 22:00-06:00 |
| 586 | return minutes >= startMin || minutes < endMin |
| 587 | } |
| 588 | |
| 589 | type heartbeatSchedule struct { |
| 590 | kind string |
| 591 | days []time.Weekday |
| 592 | month int |
| 593 | day int |
| 594 | hour int |
| 595 | minute int |
| 596 | hasRules bool |
| 597 | } |
| 598 | |
| 599 | func parseHeartbeatSchedule(interval string) (heartbeatSchedule, bool) { |
| 600 | idx := strings.Index(interval, "|") |
| 601 | if idx < 0 { |
| 602 | return heartbeatSchedule{}, false |
| 603 | } |
| 604 | raw := strings.TrimSpace(interval[idx+1:]) |
| 605 | if raw == "" { |
| 606 | return heartbeatSchedule{}, false |
| 607 | } |
| 608 | at := "09:00" |
| 609 | if parts := strings.SplitN(raw, "@", 2); len(parts) == 2 { |
| 610 | raw = parts[0] |
| 611 | at = parts[1] |
| 612 | } |
| 613 | hour, minute, ok := parseHeartbeatClock(at) |
| 614 | if !ok { |
| 615 | return heartbeatSchedule{}, false |
| 616 | } |
| 617 | kind := raw |
| 618 | rule := "" |
| 619 | if parts := strings.SplitN(raw, ":", 2); len(parts) == 2 { |
| 620 | kind = parts[0] |
| 621 | rule = parts[1] |
| 622 | } |
| 623 | s := heartbeatSchedule{kind: kind, hour: hour, minute: minute, hasRules: true} |
| 624 | switch kind { |
| 625 | case "daily": |
| 626 | return s, true |
| 627 | case "weekly", "biweekly": |
| 628 | for _, part := range strings.Split(rule, ",") { |
| 629 | if wd, ok := parseHeartbeatWeekday(part); ok { |
| 630 | s.days = append(s.days, wd) |
| 631 | } |
| 632 | } |
| 633 | return s, len(s.days) > 0 |
| 634 | case "monthly": |
| 635 | s.day = parsePositiveInt(rule, 1) |
| 636 | return s, true |
| 637 | case "yearly": |
| 638 | parts := strings.SplitN(rule, "-", 2) |
| 639 | s.month = parsePositiveInt(firstString(parts), 1) |
| 640 | s.day = 1 |
| 641 | if len(parts) == 2 { |
| 642 | s.day = parsePositiveInt(parts[1], 1) |
| 643 | } |
| 644 | if s.month < 1 { |
| 645 | s.month = 1 |
| 646 | } |
| 647 | if s.month > 12 { |
| 648 | s.month = 12 |
| 649 | } |
| 650 | return s, true |
| 651 | default: |
| 652 | return heartbeatSchedule{}, false |
| 653 | } |
| 654 | } |
| 655 | |
| 656 | func previousHeartbeatScheduleAt(t HeartbeatTask, now time.Time) (time.Time, bool) { |
| 657 | s, ok := parseHeartbeatSchedule(t.Interval) |
| 658 | if !ok || !s.hasRules { |
| 659 | return time.Time{}, false |
| 660 | } |
| 661 | switch s.kind { |
| 662 | case "daily": |
| 663 | candidate := dateAt(now.Year(), now.Month(), now.Day(), s.hour, s.minute, now.Location()) |
| 664 | if candidate.After(now) { |
| 665 | candidate = candidate.AddDate(0, 0, -1) |
| 666 | } |
| 667 | return candidate, true |
| 668 | case "weekly": |
| 669 | return previousHeartbeatWeeklyAt(s, now, 7, time.Time{}) |
| 670 | case "biweekly": |
| 671 | anchor := heartbeatScheduleAnchor(t, now) |
| 672 | return previousHeartbeatWeeklyAt(s, now, 14, anchor) |
| 673 | case "monthly": |
| 674 | return previousHeartbeatMonthlyAt(s, now), true |
| 675 | case "yearly": |
| 676 | return previousHeartbeatYearlyAt(s, now), true |
| 677 | default: |
| 678 | return time.Time{}, false |
| 679 | } |
| 680 | } |
| 681 | |
| 682 | func previousHeartbeatWeeklyAt(s heartbeatSchedule, now time.Time, windowDays int, anchor time.Time) (time.Time, bool) { |
| 683 | var best time.Time |
| 684 | for offset := 0; offset < windowDays; offset++ { |
| 685 | day := now.AddDate(0, 0, -offset) |
| 686 | for _, wd := range s.days { |
| 687 | if day.Weekday() != wd { |
| 688 | continue |
| 689 | } |
| 690 | candidate := dateAt(day.Year(), day.Month(), day.Day(), s.hour, s.minute, now.Location()) |
| 691 | if candidate.After(now) { |
| 692 | continue |
| 693 | } |
| 694 | if !anchor.IsZero() && weeksBetween(weekStart(anchor), weekStart(candidate))%2 != 0 { |
| 695 | continue |
| 696 | } |
| 697 | if best.IsZero() || candidate.After(best) { |
| 698 | best = candidate |
| 699 | } |
| 700 | } |
| 701 | } |
| 702 | return best, !best.IsZero() |
| 703 | } |
| 704 | |
| 705 | func previousHeartbeatMonthlyAt(s heartbeatSchedule, now time.Time) time.Time { |
| 706 | candidate := monthlyCandidate(now.Year(), now.Month(), s.day, s.hour, s.minute, now.Location()) |
| 707 | if candidate.After(now) { |
| 708 | prev := now.AddDate(0, -1, 0) |
| 709 | candidate = monthlyCandidate(prev.Year(), prev.Month(), s.day, s.hour, s.minute, now.Location()) |
| 710 | } |
| 711 | return candidate |
| 712 | } |
| 713 | |
| 714 | func previousHeartbeatYearlyAt(s heartbeatSchedule, now time.Time) time.Time { |
| 715 | month := time.Month(s.month) |
| 716 | candidate := monthlyCandidate(now.Year(), month, s.day, s.hour, s.minute, now.Location()) |
| 717 | if candidate.After(now) { |
| 718 | candidate = monthlyCandidate(now.Year()-1, month, s.day, s.hour, s.minute, now.Location()) |
| 719 | } |
| 720 | return candidate |
| 721 | } |
| 722 | |
| 723 | func heartbeatScheduleAnchor(t HeartbeatTask, now time.Time) time.Time { |
| 724 | if t.CreatedAt != 0 { |
| 725 | return time.UnixMilli(t.CreatedAt) |
| 726 | } |
| 727 | if t.LastRunAt != 0 { |
| 728 | return time.UnixMilli(t.LastRunAt) |
| 729 | } |
| 730 | return now |
| 731 | } |
| 732 | |
| 733 | func parseHeartbeatClock(s string) (int, int, bool) { |
| 734 | parts := strings.SplitN(strings.TrimSpace(s), ":", 2) |
| 735 | if len(parts) != 2 { |
| 736 | return 0, 0, false |
| 737 | } |
| 738 | hour := parsePositiveInt(parts[0], -1) |
| 739 | minute := parsePositiveInt(parts[1], -1) |
| 740 | if hour < 0 || hour > 23 || minute < 0 || minute > 59 { |
| 741 | return 0, 0, false |
| 742 | } |
| 743 | return hour, minute, true |
| 744 | } |
| 745 | |
| 746 | func parseHeartbeatWeekday(s string) (time.Weekday, bool) { |
| 747 | switch strings.ToLower(strings.TrimSpace(s)) { |
| 748 | case "sun": |
| 749 | return time.Sunday, true |
| 750 | case "mon": |
| 751 | return time.Monday, true |
| 752 | case "tue": |
| 753 | return time.Tuesday, true |
| 754 | case "wed": |
| 755 | return time.Wednesday, true |
| 756 | case "thu": |
| 757 | return time.Thursday, true |
| 758 | case "fri": |
| 759 | return time.Friday, true |
| 760 | case "sat": |
| 761 | return time.Saturday, true |
| 762 | default: |
| 763 | return time.Sunday, false |
| 764 | } |
| 765 | } |
| 766 | |
| 767 | func parsePositiveInt(s string, fallback int) int { |
| 768 | s = strings.TrimSpace(s) |
| 769 | if s == "" { |
| 770 | return fallback |
| 771 | } |
| 772 | n := 0 |
| 773 | for _, r := range s { |
| 774 | if r < '0' || r > '9' { |
| 775 | return fallback |
| 776 | } |
| 777 | n = n*10 + int(r-'0') |
| 778 | } |
| 779 | return n |
| 780 | } |
| 781 | |
| 782 | func firstString(values []string) string { |
| 783 | if len(values) == 0 { |
| 784 | return "" |
| 785 | } |
| 786 | return values[0] |
| 787 | } |
| 788 | |
| 789 | func dateAt(year int, month time.Month, day, hour, minute int, loc *time.Location) time.Time { |
| 790 | return time.Date(year, month, day, hour, minute, 0, 0, loc) |
| 791 | } |
| 792 | |
| 793 | func monthlyCandidate(year int, month time.Month, day, hour, minute int, loc *time.Location) time.Time { |
| 794 | if day < 1 { |
| 795 | day = 1 |
| 796 | } |
| 797 | if max := daysInMonth(year, month, loc); day > max { |
| 798 | day = max |
| 799 | } |
| 800 | return dateAt(year, month, day, hour, minute, loc) |
| 801 | } |
| 802 | |
| 803 | func daysInMonth(year int, month time.Month, loc *time.Location) int { |
| 804 | return time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day() |
| 805 | } |
| 806 | |
| 807 | func weekStart(t time.Time) time.Time { |
| 808 | dayOffset := (int(t.Weekday()) + 6) % 7 |
| 809 | base := dateAt(t.Year(), t.Month(), t.Day(), 0, 0, t.Location()) |
| 810 | return base.AddDate(0, 0, -dayOffset) |
| 811 | } |
| 812 | |
| 813 | func weeksBetween(a, b time.Time) int { |
| 814 | if b.Before(a) { |
| 815 | a, b = b, a |
| 816 | } |
| 817 | return int(b.Sub(a).Hours() / 24 / 7) |
| 818 | } |
| 819 | |
| 820 | // ── Wails bindings on App ─────────────────────────────────────────────────── |
| 821 | |
| 822 | // HeartbeatListTasks returns all heartbeat tasks. |
| 823 | func (a *App) HeartbeatListTasks() []HeartbeatTask { |
| 824 | if a.heartbeat == nil { |
| 825 | return []HeartbeatTask{} |
| 826 | } |
| 827 | return a.heartbeat.ListTasks() |
| 828 | } |
| 829 | |
| 830 | // HeartbeatReloadTasks reloads tasks from disk and returns them. |
| 831 | func (a *App) HeartbeatReloadTasks() []HeartbeatTask { |
| 832 | if a.heartbeat == nil { |
| 833 | return []HeartbeatTask{} |
| 834 | } |
| 835 | return a.heartbeat.ReloadTasks() |
| 836 | } |
| 837 | |
| 838 | // HeartbeatSaveTasks replaces the full task list and persists it. |
| 839 | func (a *App) HeartbeatSaveTasks(tasks []HeartbeatTask) error { |
| 840 | if a.heartbeat == nil { |
| 841 | return nil |
| 842 | } |
| 843 | return a.heartbeat.ReplaceTasks(tasks) |
| 844 | } |
| 845 | |
| 846 | // HeartbeatTriggerNow immediately executes the task with the given ID. |
| 847 | func (a *App) HeartbeatTriggerNow(id string) { |
| 848 | if a.heartbeat == nil { |
| 849 | return |
| 850 | } |
| 851 | a.heartbeat.TriggerNow(id) |
| 852 | } |
| 853 | |
| 854 | // HeartbeatGenerateID returns a random id for new tasks. |
| 855 | func (a *App) HeartbeatGenerateID() string { |
| 856 | const chars = "abcdefghijklmnopqrstuvwxyz0123456789" |
| 857 | b := make([]byte, 12) |
| 858 | for i := range b { |
| 859 | b[i] = chars[rand.Intn(len(chars))] |
| 860 | } |
| 861 | return string(b) |
| 862 | } |
| 863 | |
| 864 | // newBotForwarder builds event forwarding for a heartbeat turn. The caller |
| 865 | // attaches it only after acquiring the tab's turn-admission gate. |
| 866 | func (e *HeartbeatEngine) newBotForwarder(tabID string) event.Sink { |
| 867 | runtime := e.app.botRuntime |
| 868 | if runtime == nil || !runtime.Running() { |
| 869 | return nil |
| 870 | } |
| 871 | cfg, err := e.app.loadDesktopBotConfig() |
| 872 | if err != nil { |
| 873 | log.Printf("[heartbeat] load config for bot forward: %v", err) |
| 874 | return nil |
| 875 | } |
| 876 | targets := runtime.ForwardTargets(cfg) |
| 877 | if len(targets) == 0 { |
| 878 | return nil // no session-mapped channels to forward to |
| 879 | } |
| 880 | tab := e.app.tabByID(tabID) |
| 881 | if tab == nil || tab.sink == nil { |
| 882 | return nil |
| 883 | } |
| 884 | log.Printf("[heartbeat] bot forwarding attached: %d target(s) for tab %s", len(targets), tabID) |
| 885 | return newBotEventForwarder(runtime, targets) |
| 886 | } |
| 887 |