diff --git a/CHANGELOG.md b/CHANGELOG.md index aca5c160..e5fbd7d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +## [v0.50.247] — 2026-04-30 + +### Added +- **Cron job sessions auto-assigned to a dedicated "Cron Jobs" project** — sessions originating from the cron scheduler now appear in their own project group in the sidebar instead of mixed in with regular chat sessions. Detection runs against either the session's `source_tag == 'cron'` or a `cron_` ID prefix, both for live `get_cli_sessions()` calls and on `_handle_session_import_cli` import. The project is created idempotently on first cron session via `ensure_cron_project()` (thread-safe, returns the same `project_id` on every subsequent call). Locale parity across all 8 supported languages (en, es, de, zh, zh-Hant, ru, pt, ko) for the new `cron_jobs_project` key. (`api/models.py`, `api/routes.py`, `static/i18n.js`, `tests/test_1079_cron_session_project.py`) @bergeouss — PR #1345, closes #1079 + ## [v0.50.246] — 2026-04-30 ### Added diff --git a/api/models.py b/api/models.py index bf1dda8e..941f988e 100644 --- a/api/models.py +++ b/api/models.py @@ -855,6 +855,40 @@ def save_projects(projects) -> None: PROJECTS_FILE.write_text(json.dumps(projects, ensure_ascii=False, indent=2), encoding='utf-8') +CRON_PROJECT_NAME = 'Cron Jobs' +_CRON_PROJECT_LOCK = threading.Lock() + + +def ensure_cron_project() -> str: + """Return the project_id of the system "Cron Jobs" project, creating it if needed. + + Thread-safe and idempotent. Returns a 12-char hex project_id string. + """ + with _CRON_PROJECT_LOCK: + for p in load_projects(): + if p.get('name') == CRON_PROJECT_NAME: + return p['project_id'] + project_id = uuid.uuid4().hex[:12] + projects = load_projects() + projects.append({ + 'project_id': project_id, + 'name': CRON_PROJECT_NAME, + 'color': '#6366f1', + 'created_at': time.time(), + }) + save_projects(projects) + return project_id + + +def is_cron_session(session_id: str, source_tag: str = None) -> bool: + """Return True if a session originates from a cron job.""" + if source_tag == 'cron': + return True + sid = str(session_id or '') + return sid.startswith('cron_') + + + def import_cli_session( session_id: str, title: str, @@ -919,6 +953,15 @@ def get_cli_sessions() -> list: except ImportError: _cli_profile = None # older agent -- fall back to no profile + # Memoize the cron project ID for this scan so we don't pay a lock-acquire + + # disk-read of projects.json per cron session in the loop below. + # Resolved lazily on the first cron session we encounter. + _cron_pid_cache = [None] # list-as-cell so the closure can mutate + def _cron_pid(): + if _cron_pid_cache[0] is None: + _cron_pid_cache[0] = ensure_cron_project() + return _cron_pid_cache[0] + try: for row in read_importable_agent_session_rows(db_path, limit=200, log=logger, exclude_sources=None): sid = row['id'] @@ -957,7 +1000,7 @@ def get_cli_sessions() -> list: 'updated_at': raw_ts, 'pinned': False, 'archived': False, - 'project_id': None, + 'project_id': _cron_pid() if is_cron_session(sid, _source) else None, 'profile': profile, 'source_tag': _source, 'raw_source': row.get('raw_source'), diff --git a/api/routes.py b/api/routes.py index 871a5783..d1ece383 100644 --- a/api/routes.py +++ b/api/routes.py @@ -488,6 +488,8 @@ from api.models import ( import_cli_session, get_cli_sessions, get_cli_session_messages, + ensure_cron_project, + is_cron_session, ) from api.workspace import ( load_workspaces, @@ -4194,6 +4196,7 @@ def _handle_session_import_cli(handler, body): created_at = None updated_at = None cli_title = None + cli_source_tag = None for cs in get_cli_sessions(): if cs["session_id"] == sid: profile = cs.get("profile") @@ -4201,11 +4204,17 @@ def _handle_session_import_cli(handler, body): created_at = cs.get("created_at") updated_at = cs.get("updated_at") cli_title = cs.get("title") + cli_source_tag = cs.get("source_tag") break # Use the CLI session title if available (e.g., cron job name), otherwise derive from messages title = cli_title or title_from(msgs, "CLI Session") + # Auto-assign cron sessions to the dedicated "Cron Jobs" project (#1079) + cron_project_id = None + if is_cron_session(sid, cli_source_tag): + cron_project_id = ensure_cron_project() + s = import_cli_session( sid, title, @@ -4215,6 +4224,8 @@ def _handle_session_import_cli(handler, body): created_at=created_at, updated_at=updated_at, ) + if cron_project_id: + s.project_id = cron_project_id s.is_cli_session = True s._cli_origin = sid s.save(touch_updated_at=False) diff --git a/static/i18n.js b/static/i18n.js index 9b29b26e..2f994a20 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -609,6 +609,7 @@ const LOCALES = { cron_attention_desc: 'This recurring job has no next run time. The scheduler may have failed to compute its next run.', cron_attention_croniter_hint: 'The Gateway runtime may be missing the croniter package. Restart the Gateway with cron support, then resume this job.', cron_attention_resume: 'Resume and recalculate', + cron_jobs_project: 'Cron Jobs', cron_attention_run_once: 'Run once now', cron_attention_copy_diagnostics: 'Copy diagnostics', cron_diagnostics_copied: 'Cron diagnostics copied', @@ -1241,6 +1242,7 @@ const LOCALES = { cron_attention_desc: 'У этого повторяющегося задания нет времени следующего запуска. Планировщик мог не суметь вычислить следующий запуск.', cron_attention_croniter_hint: 'В окружении Gateway может отсутствовать пакет croniter. Перезапустите Gateway с поддержкой cron, затем возобновите это задание.', cron_attention_resume: 'Возобновить и пересчитать', + cron_jobs_project: 'Задания Cron', cron_attention_run_once: 'Запустить один раз сейчас', cron_attention_copy_diagnostics: 'Скопировать диагностику', cron_diagnostics_copied: 'Диагностика cron скопирована', @@ -1989,6 +1991,7 @@ const LOCALES = { cron_attention_desc: 'This recurring job has no next run time. The scheduler may have failed to compute its next run.', cron_attention_croniter_hint: 'The Gateway runtime may be missing the croniter package. Restart the Gateway with cron support, then resume this job.', cron_attention_resume: 'Resume and recalculate', + cron_jobs_project: 'Tareas Programadas', cron_attention_run_once: 'Run once now', cron_attention_copy_diagnostics: 'Copy diagnostics', cron_diagnostics_copied: 'Cron diagnostics copied', @@ -2844,6 +2847,7 @@ const LOCALES = { cron_attention_desc: 'Dieser Job hat Probleme.', cron_attention_croniter_hint: 'Der Croniter-Ausdruck ist möglicherweise ungültig.', cron_attention_resume: 'Fortsetzen', + cron_jobs_project: 'Cron-Jobs', cron_attention_run_once: 'Einmal ausführen', cron_attention_copy_diagnostics: 'Diagnose kopieren', cron_diagnostics_copied: 'Diagnoseinformationen kopiert.', @@ -3453,6 +3457,7 @@ const LOCALES = { cron_attention_desc: '这个重复定时任务没有下次运行时间。调度器可能没能计算出下一次运行。', cron_attention_croniter_hint: 'Gateway 运行环境可能缺少 croniter 包。请用支持 cron 的环境重启 Gateway,然后恢复这个任务。', cron_attention_resume: '恢复并重新计算', + cron_jobs_project: '定时任务', cron_attention_run_once: '立即运行一次', cron_attention_copy_diagnostics: '复制诊断信息', cron_diagnostics_copied: '定时任务诊断信息已复制', @@ -4400,6 +4405,7 @@ const LOCALES = { cron_attention_desc: '\u9019\u500b\u91cd\u8907\u6392\u7a0b\u4efb\u52d9\u6c92\u6709\u4e0b\u6b21\u57f7\u884c\u6642\u9593\u3002\u6392\u7a0b\u5668\u53ef\u80fd\u7121\u6cd5\u8a08\u7b97\u4e0b\u4e00\u6b21\u57f7\u884c\u3002', cron_attention_croniter_hint: 'Gateway \u57f7\u884c\u74b0\u5883\u53ef\u80fd\u7f3a\u5c11 croniter \u5957\u4ef6\u3002\u8acb\u7528\u652f\u63f4 cron \u7684\u74b0\u5883\u91cd\u555f Gateway\uff0c\u7136\u5f8c\u6062\u5fa9\u9019\u500b\u4efb\u52d9\u3002', cron_attention_resume: '\u6062\u5fa9\u4e26\u91cd\u65b0\u8a08\u7b97', + cron_jobs_project: '\u5b9a\u6642\u4efb\u52d9', cron_attention_run_once: '\u7acb\u5373\u57f7\u884c\u4e00\u6b21', cron_attention_copy_diagnostics: '\u8907\u88fd\u8a3a\u65b7\u8cc7\u8a0a', cron_diagnostics_copied: '\u6392\u7a0b\u4efb\u52d9\u8a3a\u65b7\u8cc7\u8a0a\u5df2\u8907\u88fd', @@ -5080,6 +5086,7 @@ const LOCALES = { cron_attention_desc: 'Esta tarefa não tem próxima execução. Scheduler pode não ter calculado.', cron_attention_croniter_hint: 'Gateway pode não ter pacote croniter. Reinicie com cron support.', cron_attention_resume: 'Retomar e recalcular', + cron_jobs_project: 'Tarefas Agendadas', cron_attention_run_once: 'Rodar uma vez agora', cron_attention_copy_diagnostics: 'Copiar diagnóstico', cron_diagnostics_copied: 'Diagnóstico copiado', @@ -5790,6 +5797,7 @@ const LOCALES = { cron_attention_desc: '이 반복 작업에 다음 실행 시간이 없습니다. 스케줄러가 다음 실행을 계산하지 못했을 수 있습니다.', cron_attention_croniter_hint: 'Gateway 런타임에 croniter 패키지가 없을 수 있습니다. cron 지원이 포함된 환경으로 Gateway를 재시작한 후 이 작업을 재개하세요.', cron_attention_resume: '재개 및 재계산', + cron_jobs_project: '예약 작업', cron_attention_run_once: '지금 한 번 실행', cron_attention_copy_diagnostics: '진단 정보 복사', cron_diagnostics_copied: '크론 진단 정보가 복사되었습니다', diff --git a/tests/test_1079_cron_session_project.py b/tests/test_1079_cron_session_project.py new file mode 100644 index 00000000..c5108045 --- /dev/null +++ b/tests/test_1079_cron_session_project.py @@ -0,0 +1,89 @@ +"""Tests for #1079: Auto-assign cron job sessions to dedicated 'Cron Jobs' project.""" + +import json +import pathlib +import shutil +import tempfile + +import pytest + +from tests.conftest import TEST_STATE_DIR, _post, TEST_BASE + +pytestmark = pytest.mark.usefixtures("test_server") + + +def _get_projects(base_url): + """Fetch the project list from the API.""" + import urllib.request + with urllib.request.urlopen(base_url + "/api/projects", timeout=5) as r: + return json.loads(r.read()) + + +def test_ensure_cron_project_creates_project(): + """ensure_cron_project() should create a 'Cron Jobs' project if none exists.""" + from api.models import ensure_cron_project, load_projects, save_projects + + # Remove any existing Cron Jobs project to test creation + projects = load_projects() + original = [p for p in projects if p.get('name') != 'Cron Jobs'] + save_projects(original) + + pid = ensure_cron_project() + + # Should now exist + projects = load_projects() + cron_projects = [p for p in projects if p.get('name') == 'Cron Jobs'] + assert len(cron_projects) == 1 + assert cron_projects[0]['project_id'] == pid + assert cron_projects[0]['color'] == '#6366f1' + assert len(pid) == 12 + + # Restore + save_projects(projects) + + +def test_ensure_cron_project_idempotent(): + """Calling ensure_cron_project() twice should return the same ID.""" + from api.models import ensure_cron_project, load_projects, save_projects + + projects = load_projects() + save_projects([p for p in projects if p.get('name') != 'Cron Jobs']) + + pid1 = ensure_cron_project() + pid2 = ensure_cron_project() + assert pid1 == pid2 + + +def test_is_cron_session(): + """is_cron_session should detect cron sessions by source_tag or ID prefix.""" + from api.models import is_cron_session + + # By source_tag + assert is_cron_session("any_id", source_tag="cron") is True + assert is_cron_session("any_id", source_tag="cli") is False + + # By session ID prefix + assert is_cron_session("cron_abc123") is True + assert is_cron_session("cron_") is True + assert is_cron_session("regular_session") is False + assert is_cron_session(None) is False + assert is_cron_session("") is False + + +def test_cron_jobs_project_i18n_key_exists(): + """All 8 locales must have the cron_jobs_project i18n key.""" + i18n_path = pathlib.Path(__file__).resolve().parent.parent / "static" / "i18n.js" + content = i18n_path.read_text(encoding="utf-8") + + # Count occurrences of cron_jobs_project + count = content.count("cron_jobs_project:") + assert count == 8, f"Expected 8 locale entries for cron_jobs_project, found {count}" + + +def test_cron_session_gets_project_id_in_cli_list(): + """get_cli_sessions() should assign project_id for cron sessions.""" + from api.models import get_cli_sessions + # Just verify the function is callable and returns a list + # The actual project assignment is tested indirectly via integration + sessions = get_cli_sessions() + assert isinstance(sessions, list)