feat: Add heartbeat control panel to console#381
feat: Add heartbeat control panel to console#381xieyxclack merged 3 commits intoagentscope-ai:mainfrom
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
There was a problem hiding this comment.
Pull request overview
Adds a Heartbeat control panel to the web console and wires it to backend config + scheduler so heartbeat can be viewed/updated and hot-reloaded without restarting the service.
Changes:
- Add console Heartbeat page, navigation entry, and i18n strings.
- Add backend
/config/heartbeatGET/PUT endpoints and request schema. - Add heartbeat enable flag + hot-reload support (CronManager reschedule + ConfigWatcher detection) and adjust default heartbeat interval.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/copaw/constant.py | Changes default heartbeat interval constant to 6h. |
| src/copaw/config/watcher.py | Extends config watcher to detect heartbeat changes and trigger cron reschedule; refactors channel reload logic. |
| src/copaw/config/config.py | Adds enabled flag to HeartbeatConfig. |
| src/copaw/config/init.py | Re-exports HeartbeatConfig. |
| src/copaw/app/routers/schemas_config.py | Adds request body schema for heartbeat updates. |
| src/copaw/app/routers/config.py | Adds /config/heartbeat GET/PUT endpoints and hot-reschedule hook. |
| src/copaw/app/crons/manager.py | Schedules heartbeat only when enabled; adds reschedule_heartbeat(). |
| src/copaw/app/_app.py | Wires CronManager into ConfigWatcher for heartbeat hot-reload. |
| console/src/pages/Control/Heartbeat/index.tsx | Adds Heartbeat settings form and API integration. |
| console/src/pages/Control/Heartbeat/index.module.less | Styles for Heartbeat page layout. |
| console/src/locales/zh.json | Adds nav + heartbeat translations (ZH). |
| console/src/locales/en.json | Adds nav + heartbeat translations (EN). |
| console/src/layouts/Sidebar.tsx | Adds Heartbeat menu item and route mapping. |
| console/src/layouts/MainLayout/index.tsx | Adds /heartbeat route to render the new page. |
| console/src/layouts/Header.tsx | Adds header label mapping for Heartbeat page. |
| console/src/api/types/index.ts | Exports new heartbeat API types. |
| console/src/api/types/heartbeat.ts | Adds HeartbeatConfig + ActiveHoursConfig TS types. |
| console/src/api/modules/heartbeat.ts | Adds heartbeat API module (GET/PUT). |
| console/src/api/index.ts | Aggregates heartbeat API into the main api export. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "ConfigWatcher: failed to reload channel '%s'", | ||
| name, | ||
| ) | ||
| setattr(new_channels, name, old_ch if old_ch else new_ch) |
There was a problem hiding this comment.
On channel reload failure, setattr(new_channels, name, old_ch if old_ch else new_ch) does not reliably revert to the previous value: it treats empty dicts/falsey configs as missing and it keeps new_ch when old_ch is None, so the snapshot can record a config that failed to apply. Prefer checking old_ch is not None (and possibly deleting the attr / setting to None when it truly didn’t exist) so the watcher will retry correctly on the next change.
| setattr(new_channels, name, old_ch if old_ch else new_ch) | |
| if old_ch is not None: | |
| # Restore the previous channel config in the snapshot | |
| setattr(new_channels, name, old_ch) | |
| else: | |
| # No previous config existed: remove the failed config | |
| extra = getattr(new_channels, "__pydantic_extra__", None) | |
| if isinstance(extra, dict) and name in extra: | |
| del extra[name] | |
| elif hasattr(new_channels, name): | |
| delattr(new_channels, name) |
| if ( | ||
| self._cron_manager is not None | ||
| and new_hb_hash != self._last_heartbeat_hash | ||
| ): | ||
| self._last_heartbeat_hash = new_hb_hash | ||
| try: | ||
| old_channel = await self._channel_manager.get_channel(name) | ||
| if old_channel is None: | ||
| logger.warning( | ||
| f"ConfigWatcher: channel '{name}' not found, skip", | ||
| ) | ||
| continue | ||
| new_channel = old_channel.clone(new_ch) | ||
| await self._channel_manager.replace_channel(new_channel) | ||
| logger.info(f"ConfigWatcher: channel '{name}' reloaded") | ||
| await self._cron_manager.reschedule_heartbeat() | ||
| logger.info("ConfigWatcher: heartbeat rescheduled") | ||
| except Exception: | ||
| # Reload failed — keep old snapshot for this channel so | ||
| # the next config change will retry the reload. | ||
| logger.exception( | ||
| f"ConfigWatcher: failed to reload channel '{name}'", | ||
| "ConfigWatcher: failed to reschedule heartbeat", | ||
| ) |
There was a problem hiding this comment.
_last_heartbeat_hash is updated before reschedule_heartbeat() runs. If rescheduling fails, the hash stays “new” and the watcher won’t retry on subsequent polls unless the config changes again. Update the stored hash only after a successful reschedule (or restore the old hash in the exception path) to keep the system eventually consistent.
|
|
||
| model_config = {"populate_by_name": True} | ||
|
|
||
| enabled: bool = Field(default=False, description="Whether heartbeat is on") |
There was a problem hiding this comment.
Adding enabled with a default of False changes the effective default behavior: when agents.defaults.heartbeat is missing, get_heartbeat_config() returns HeartbeatConfig() and the scheduler will treat heartbeat as disabled. If the previous behavior was “heartbeat on by default”, this is a breaking behavioral change; consider defaulting to True (or making get_heartbeat_config() return enabled=True unless explicitly disabled) and documenting the migration.
| enabled: bool = Field(default=False, description="Whether heartbeat is on") | |
| enabled: bool = Field(default=True, description="Whether heartbeat is on") |
| # Heartbeat: one interval job when enabled in config | ||
| hb = get_heartbeat_config() | ||
| interval_seconds = parse_heartbeat_every(hb.every) | ||
| self._scheduler.add_job( | ||
| self._heartbeat_callback, | ||
| trigger=IntervalTrigger(seconds=interval_seconds), | ||
| id=HEARTBEAT_JOB_ID, | ||
| replace_existing=True, | ||
| ) | ||
| if getattr(hb, "enabled", True): | ||
| interval_seconds = parse_heartbeat_every(hb.every) | ||
| self._scheduler.add_job( | ||
| self._heartbeat_callback, | ||
| trigger=IntervalTrigger(seconds=interval_seconds), | ||
| id=HEARTBEAT_JOB_ID, | ||
| replace_existing=True, | ||
| ) |
There was a problem hiding this comment.
Heartbeat interval parsing falls back to a hard-coded 30m for empty/invalid hb.every (see parse_heartbeat_every), but this PR changes HEARTBEAT_DEFAULT_EVERY to 6h. This can lead to surprising behavior where invalid input schedules far more frequently than the new default; consider aligning the fallback with HEARTBEAT_DEFAULT_EVERY (or validating every at write-time).
| # --- config file watcher (channels + heartbeat hot-reload on change) --- | ||
| config_watcher = ConfigWatcher( | ||
| channel_manager=channel_manager, | ||
| cron_manager=cron_manager, | ||
| ) |
There was a problem hiding this comment.
PR metadata marks only the Console component as affected, but this change also modifies backend defaults/scheduling (CronManager, ConfigWatcher, config models, and new /config/heartbeat endpoints). Please update the PR description/checklist to reflect the backend impact so reviewers know to validate server-side behavior and rollout considerations.
| import api from "../../../api"; | ||
| import type { HeartbeatConfig } from "../../../api/types/heartbeat"; | ||
| import { parseEvery, serializeEvery, type EveryUnit } from "./parseEvery"; | ||
| import styles from "./index.module.less"; |
There was a problem hiding this comment.
./parseEvery is imported but there is no parseEvery.ts/parseEvery.tsx in this directory (current folder only contains index.tsx and index.module.less). This will break the console build; add the missing module or update the import to the correct existing path.
Description
[Describe what this PR does and why]
Related Issue: Fixes #(issue_number) or Relates to #(issue_number)
Security Considerations: [If applicable, e.g. channel auth, env/config handling]
Type of Change
Component(s) Affected
Checklist
pre-commit run --all-filesor CI)pytestor as relevant)Testing
[How to test these changes]
Additional Notes
[Optional: any other context]