返回 DeepSeek-Reasonix
heartbeat.go
根目录 / desktop / heartbeat.go
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
887 lines GO