Merge pull request #1347 from nesquena/release/v0.50.247
release: v0.50.247 — Cron Jobs project auto-assignment (#1345)
This commit is contained in:
commit
f70b791bf8
5 changed files with 157 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: '크론 진단 정보가 복사되었습니다',
|
||||
|
|
|
|||
89
tests/test_1079_cron_session_project.py
Normal file
89
tests/test_1079_cron_session_project.py
Normal file
|
|
@ -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)
|
||||
Loading…
Reference in a new issue