Merge pull request #7664 from nesquena/stage/7649-7630-cron-projects
Release: cron schedule round-trip (#7649) + system-project profile tagging (#7630)
This commit is contained in:
9 files changed
+589
-19
No files matched your search
@@ -5,6 +5,10 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Editing or duplicating a one-shot cron job no longer returns HTTP 500.** The editable schedule field was populated from `schedule_display`, the human-readable label (`once at 2026-08-28 16:00`), and saving submitted that label straight back. The Agent's schedule parser only accepts the canonical timestamp, so it raised — and the route's existing `ValueError` guard did not cover the `update_job()` call, so the failure escaped as a 500. The field now carries the canonical `run_at` for one-shot jobs, and genuinely unparseable input returns a 400 with the parser's message instead of a server error. Natural-language recurring schedules are preserved verbatim: hardening during review found that preferring the canonical cron expression rewrote a job scheduled as `every monday 9am` into `0 9 * * 1` on every edit or duplicate — and because the Agent rebuilds the display string from whatever is submitted, the rewrite stuck and the user silently lost their phrasing. Only the `once at …` label is unparseable, so every other display form is now kept as typed. Thanks @happy5318. (#7649, #7352)
|
||||
|
||||
- **"Webhooks" and "Cron Jobs" projects stop appearing on profiles that have none.** `get_cli_sessions(all_profiles=True)` walks every profile's `state.db` in one request, and when the walk reached a profile holding webhook or cron rows it lazily minted the system project — stamping it with the **UI-selected** profile rather than the profile whose rows triggered the mint. So a webhook project from one profile materialized on whichever profile happened to be open, deleting it from `projects.json` did not help because the next sidebar poll re-minted it, and switching profiles produced another copy. Both mint paths now tag the scanned profile. Single-profile calls keep their previous behavior, and no existing project data is overwritten — a pre-existing mis-tagged entry stays until removed, alongside the correctly tagged one. Thanks @carlotestor. (#7630)
|
||||
|
||||
- **Multi-frame phone photos stop dragging down session responses.** Multi-Picture Format JPEGs — the multi-frame containers phone cameras routinely produce — were being rejected at the first EOI marker, so they missed the native-image exemption and were handed to the full text-secret scan instead. On a real 30-message payload carrying 12 such images that cost 8.4 s of redaction per response; it is now 0.63 s, with every image byte preserved and non-image output identical. The exemption is granted only after the container fully validates: declared extents must tile the file contiguously to EOF, every secondary JPEG must itself be valid, and malformed offsets, bad counts or sizes, either byte order, overlapping or nested extents, gaps, truncation and undeclared trailing bytes all fail closed back to the normal redaction path rather than being exempted. Parser work is bounded by the APP2 segment size and secondary parsing cannot recurse. Verified against 10 hand-built malformed headers and 20,000 deterministic mutations with no exception raised. Thanks @GeorgeCodes19. (#7641)
|
||||
|
||||
- **HTTP access logs survive agent stdout capture.** Agent and tool code replaces or closes `sys.stdout` to capture subprocess output, and the server's per-request access records were being swallowed into that capture instead of reaching the service log — so requests made while a tool was running simply vanished from the log. Access-log records now go through a dedicated writer that owns its own duplicate of the process's original stdout descriptor, taken at import time before any agent code loads, so replacing or closing Python's `sys.stdout` can no longer intercept them. Delivery is proven end to end by a subprocess test that redirects and then closes `sys.stdout` and still observes the records on the real process stdout. Record format, destination and ordering are unchanged for a normal launch, the write is guarded so a broken sink can never break an HTTP response, and `SIGPIPE` is already ignored before serving starts. Thanks @DevNexsler. (#7643, #2684)
|
||||
|
||||
+15
-9
@@ -6712,7 +6712,7 @@ CRON_PROJECT_NAME = 'Cron Jobs'
|
||||
_CRON_PROJECT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def ensure_cron_project(create: bool = True) -> str | None:
|
||||
def ensure_cron_project(create: bool = True, profile: str | None = None) -> str | None:
|
||||
"""Return the project_id of the system "Cron Jobs" project for the active profile.
|
||||
|
||||
Each profile gets its own "Cron Jobs" project so cron-spawned sessions in
|
||||
@@ -6734,7 +6734,10 @@ def ensure_cron_project(create: bool = True) -> str | None:
|
||||
"""
|
||||
from api.profiles import get_active_profile_name, _is_root_profile
|
||||
|
||||
active = get_active_profile_name() or 'default'
|
||||
# `profile` is the owner of the state.db being projected; the active
|
||||
# profile is only a fallback so a cross-profile scan never tags another
|
||||
# profile's system project onto the one currently selected.
|
||||
active = profile or get_active_profile_name() or 'default'
|
||||
with _CRON_PROJECT_LOCK:
|
||||
projects = load_projects()
|
||||
# Look for an existing per-profile cron project. Match either an exact
|
||||
@@ -6774,11 +6777,12 @@ WEBHOOK_PROJECT_NAME = 'Webhooks'
|
||||
_WEBHOOK_PROJECT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def ensure_webhook_project() -> str:
|
||||
"""Return the project_id of the system "Webhooks" project for the active profile."""
|
||||
def ensure_webhook_project(profile: str | None = None) -> str:
|
||||
"""Return the project_id of the system "Webhooks" project for `profile` (default: active)."""
|
||||
from api.profiles import get_active_profile_name, _is_root_profile
|
||||
|
||||
active = get_active_profile_name() or 'default'
|
||||
# Owner of the scanned state.db wins over the selected profile (see ensure_cron_project).
|
||||
active = profile or get_active_profile_name() or 'default'
|
||||
with _WEBHOOK_PROJECT_LOCK:
|
||||
projects = load_projects()
|
||||
for p in projects:
|
||||
@@ -6806,7 +6810,7 @@ def ensure_webhook_project() -> str:
|
||||
return project_id
|
||||
|
||||
|
||||
def _profile_has_user_projects() -> bool:
|
||||
def _profile_has_user_projects(profile: str | None = None) -> bool:
|
||||
"""True if the active profile already has at least one real (non-system) project.
|
||||
|
||||
"Opted into project organization" means `load_projects()` contains a
|
||||
@@ -6819,7 +6823,7 @@ def _profile_has_user_projects() -> bool:
|
||||
"""
|
||||
from api.profiles import get_active_profile_name, _is_root_profile
|
||||
|
||||
active = get_active_profile_name() or 'default'
|
||||
active = profile or get_active_profile_name() or 'default'
|
||||
reserved = {CRON_PROJECT_NAME, WEBHOOK_PROJECT_NAME}
|
||||
for p in load_projects():
|
||||
if p.get('name') in reserved:
|
||||
@@ -7747,7 +7751,9 @@ def _load_cli_sessions_uncached(
|
||||
def _cron_pid():
|
||||
if not _cron_pid_cache[0]:
|
||||
_cron_pid_cache[0] = True
|
||||
_cron_pid_cache[1] = ensure_cron_project(create=_profile_has_user_projects())
|
||||
_cron_pid_cache[1] = ensure_cron_project(
|
||||
create=_profile_has_user_projects(_cli_profile), profile=_cli_profile,
|
||||
)
|
||||
return _cron_pid_cache[1]
|
||||
|
||||
# Memoize the cron jobs.json job_id -> name map for this scan. The two row
|
||||
@@ -7798,7 +7804,7 @@ def _load_cli_sessions_uncached(
|
||||
_webhook_pid_cache: list[str | None] = [None]
|
||||
def _webhook_pid():
|
||||
if _webhook_pid_cache[0] is None:
|
||||
_webhook_pid_cache[0] = ensure_webhook_project()
|
||||
_webhook_pid_cache[0] = ensure_webhook_project(profile=_cli_profile)
|
||||
return _webhook_pid_cache[0]
|
||||
|
||||
def _state_row_project_id(sid: str, source: str | None) -> str | None:
|
||||
|
||||
+11
-1
@@ -25011,7 +25011,17 @@ def _handle_cron_update(handler, body):
|
||||
updates[k] = v
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
job = update_job(body["job_id"], updates)
|
||||
# #7352: ``update_job`` re-parses the updated schedule through
|
||||
# ``cron.jobs.parse_schedule``, which raises ``ValueError`` for
|
||||
# display-form input like ``"once at 2026-08-28 16:05"`` (or any other
|
||||
# user-typed garbage). That exception previously escaped to a 500
|
||||
# because the earlier normalization try block didn't cover it. Return
|
||||
# 400 with the parser message so the WebUI can surface a real
|
||||
# validation error instead of an opaque Internal Server Error.
|
||||
try:
|
||||
job = update_job(body["job_id"], updates)
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e), 400)
|
||||
if not job:
|
||||
return bad(handler, "Job not found", 404)
|
||||
return j(handler, {"ok": True, "job": _cron_job_for_api(job)})
|
||||
|
||||
+40
-2
@@ -1507,6 +1507,44 @@ function editCurrentCron(){
|
||||
if (!_currentCronDetail) return;
|
||||
openCronEdit(_currentCronDetail);
|
||||
}
|
||||
function _cronScheduleForEdit(job){
|
||||
// #7352: when the user opens an existing job for edit or duplicate, the
|
||||
// editable field must hold the canonical Agent-parseable schedule, not
|
||||
// the human-readable ``schedule_display`` ("once at ..."). The Agent
|
||||
// parser at cron/jobs.py rejects the display form with ValueError and
|
||||
// the resulting round-trip has been failing with HTTP 500 since the
|
||||
// initial WebUI release.
|
||||
if(!job) return '';
|
||||
const sched = job.schedule;
|
||||
// Only the "once at ..." label is unparseable; every other schedule_display
|
||||
// form the Agent emits (`every monday 9am`, `every 30m`, ...) round-trips
|
||||
// through parse_schedule() AND is what the user actually typed. Prefer it
|
||||
// over the canonical `expr`, otherwise editing or duplicating a
|
||||
// natural-language recurring job silently rewrites it to raw cron
|
||||
// (`every monday 9am` -> `0 9 * * 1`) — the Agent rebuilds schedule_display
|
||||
// from whatever we submit, so the rewrite sticks.
|
||||
const display = job.schedule_display;
|
||||
const displayIsParseable = Boolean(display) && !/^\s*once at\s+/i.test(display);
|
||||
if(sched && typeof sched === 'object'){
|
||||
// One-shot: schedule_display is purely presentation; run_at is what the
|
||||
// parser accepts.
|
||||
if(sched.kind === 'once' && sched.run_at) return sched.run_at;
|
||||
if(displayIsParseable) return display;
|
||||
// Recurring cron: the current Agent schema is ``expr``; some legacy
|
||||
// payloads still expose ``expression`` — accept either.
|
||||
if(sched.expr) return sched.expr;
|
||||
if(sched.expression) return sched.expression;
|
||||
if(sched.run_at) return sched.run_at;
|
||||
}
|
||||
// Final fallback: only use schedule_display if it isn't the "once at ..."
|
||||
// presentation label (which the parser rejects). For any other text
|
||||
// (e.g. interval/every-30m) the display form is also a valid input.
|
||||
if(displayIsParseable){
|
||||
return display;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function duplicateCurrentCron(){
|
||||
if (!_currentCronDetail) return;
|
||||
const job = _currentCronDetail;
|
||||
@@ -1529,7 +1567,7 @@ function duplicateCurrentCron(){
|
||||
}
|
||||
_renderCronForm({
|
||||
name: dupName,
|
||||
schedule: job.schedule_display || (job.schedule && job.schedule.expression) || '',
|
||||
schedule: _cronScheduleForEdit(job),
|
||||
prompt: job.prompt || '',
|
||||
deliver: job.deliver || 'local',
|
||||
profile: job.profile || '',
|
||||
@@ -1590,7 +1628,7 @@ function openCronEdit(job){
|
||||
_cronSelectedSkills = Array.isArray(job.skills) ? [...job.skills] : [];
|
||||
_renderCronForm({
|
||||
name: job.name || '',
|
||||
schedule: job.schedule_display || (job.schedule && job.schedule.expression) || '',
|
||||
schedule: _cronScheduleForEdit(job),
|
||||
prompt: job.prompt || '',
|
||||
deliver: job.deliver || 'local',
|
||||
profile: job.profile || '',
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Regression coverage for issue #7352: cron edit/duplicate uses
|
||||
``schedule_display`` ("once at ...") as editable input, which the Agent
|
||||
parser at ``cron.jobs.parse_schedule`` rejects with ``ValueError`` and
|
||||
the resulting round-trip has been failing with HTTP 500 since the
|
||||
WebUI's initial public release.
|
||||
|
||||
Two layers are fixed in this regression:
|
||||
|
||||
1. ``static/panels.js`` adds ``_cronScheduleForEdit`` and uses it in
|
||||
both ``openCronEdit`` and ``duplicateCurrentCron`` so the editable
|
||||
field holds the canonical Agent-parseable value (one-shot
|
||||
``schedule.run_at``; recurring ``schedule.expr``; interval keeps its
|
||||
existing text).
|
||||
|
||||
2. ``api/routes.py::_handle_cron_update`` now wraps the ``update_job``
|
||||
call in ``try/except ValueError`` and returns HTTP 400 with the
|
||||
parser message instead of letting the exception escape as a 500.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class _JSONHandler:
|
||||
def __init__(self):
|
||||
self.status = None
|
||||
self.headers = {}
|
||||
self.response_headers = []
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.response_headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
|
||||
def _payload(handler):
|
||||
return json.loads(handler.wfile.getvalue().decode("utf-8"))
|
||||
|
||||
|
||||
def _function_body(name: str) -> str:
|
||||
marker = f"function {name}("
|
||||
start = PANELS_JS.find(marker)
|
||||
assert start != -1, f"{name} not found"
|
||||
paren = PANELS_JS.find("(", start)
|
||||
assert paren != -1, f"{name} params not found"
|
||||
depth = 0
|
||||
for idx in range(paren, len(PANELS_JS)):
|
||||
ch = PANELS_JS[idx]
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
brace = PANELS_JS.find("{", idx)
|
||||
break
|
||||
else:
|
||||
raise AssertionError(f"{name} params did not terminate")
|
||||
assert brace != -1, f"{name} body not found"
|
||||
depth = 0
|
||||
for idx in range(brace, len(PANELS_JS)):
|
||||
ch = PANELS_JS[idx]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return PANELS_JS[brace + 1 : idx]
|
||||
raise AssertionError(f"{name} body did not terminate")
|
||||
|
||||
|
||||
# --- Frontend: _cronScheduleForEdit helper exists and is referenced ----------
|
||||
|
||||
|
||||
def test_cron_schedule_for_edit_helper_exists():
|
||||
"""A canonical-by-kind schedule picker is required so that opening
|
||||
an existing one-shot job for edit/duplicate populates the editable
|
||||
field with the Agent-parseable ``run_at`` rather than the
|
||||
presentation-only ``schedule_display`` label."""
|
||||
body = _function_body("_cronScheduleForEdit")
|
||||
assert body, "_cronScheduleForEdit must exist"
|
||||
# One-shot: use run_at, never the display label.
|
||||
assert "kind === 'once'" in body
|
||||
assert "run_at" in body
|
||||
# Recurring cron: prefer the current Agent schema field ``expr``;
|
||||
# accept legacy ``expression`` for back-compat.
|
||||
assert "sched.expr" in body
|
||||
assert "sched.expression" in body
|
||||
# Final fallback: only use schedule_display if it isn't the unsafe
|
||||
# ``once at ...`` presentation form.
|
||||
assert "schedule_display" in body
|
||||
assert "once at" in body
|
||||
assert "return ''" in body
|
||||
|
||||
|
||||
def test_open_cron_edit_uses_helper():
|
||||
"""openCronEdit must call _cronScheduleForEdit, not the legacy
|
||||
``schedule_display || schedule.expression`` chain that round-trips
|
||||
the unsafe display label back to the Agent parser."""
|
||||
body = _function_body("openCronEdit")
|
||||
assert "schedule: _cronScheduleForEdit(job)" in body
|
||||
# Legacy chain must be gone from this function.
|
||||
assert "job.schedule_display" not in body
|
||||
assert "job.schedule.expression" not in body
|
||||
|
||||
|
||||
def test_duplicate_current_cron_uses_helper():
|
||||
"""duplicateCurrentCron has the same root cause as openCronEdit; the
|
||||
fix must cover both call sites or duplicating a one-shot job
|
||||
would also fail."""
|
||||
body = _function_body("duplicateCurrentCron")
|
||||
assert "schedule: _cronScheduleForEdit(job)" in body
|
||||
assert "job.schedule_display" not in body
|
||||
|
||||
|
||||
def test_save_cron_form_still_submits_schedule_value():
|
||||
"""Regression guard: the saved-field name stays ``schedule`` and
|
||||
the helper must not have leaked into the submit path (which already
|
||||
reads ``schEl.value`` and submits it unchanged)."""
|
||||
body = _function_body("saveCronForm")
|
||||
assert "const schedule=schEl.value.trim();" in body
|
||||
assert "schedule," in body
|
||||
|
||||
|
||||
# --- Backend: _handle_cron_update converts ValueError to HTTP 400 --------------
|
||||
|
||||
|
||||
def _install_cron_jobs_stub(monkeypatch, *, update_fn):
|
||||
cron_pkg = types.ModuleType("cron")
|
||||
cron_pkg.__path__ = []
|
||||
cron_jobs = types.ModuleType("cron.jobs")
|
||||
cron_jobs.update_job = update_fn
|
||||
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
||||
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
||||
|
||||
|
||||
def test_cron_update_value_error_returns_400(monkeypatch):
|
||||
"""#7352: when the Agent parser rejects the submitted schedule with
|
||||
ValueError (the "once at ..." case before the frontend fix, or any
|
||||
user-typed garbage), the route must return 400 with the parser
|
||||
message instead of letting the exception escape as 500."""
|
||||
import api.routes as routes
|
||||
|
||||
def _raise(job_id, updates):
|
||||
raise ValueError(
|
||||
"Invalid schedule 'once at 2026-08-28 16:05'. Use:\n"
|
||||
" - Timestamp: '2026-02-03T14:00:00' (one-shot at time)"
|
||||
)
|
||||
|
||||
_install_cron_jobs_stub(monkeypatch, update_fn=_raise)
|
||||
|
||||
handler = _JSONHandler()
|
||||
routes._handle_cron_update(
|
||||
handler,
|
||||
{
|
||||
"job_id": "test-job",
|
||||
"schedule": "once at 2026-08-28 16:05",
|
||||
},
|
||||
)
|
||||
|
||||
assert handler.status == 400, (
|
||||
"schedule validation failures must surface as 400, not 500 "
|
||||
"(#7352)"
|
||||
)
|
||||
payload = _payload(handler)
|
||||
assert "error" in payload
|
||||
assert "Invalid schedule" in payload["error"], (
|
||||
"parser message must propagate so the WebUI can show a useful "
|
||||
"validation toast"
|
||||
)
|
||||
assert "once at 2026-08-28 16:05" in payload["error"]
|
||||
|
||||
|
||||
def test_cron_update_preserves_404_when_job_missing(monkeypatch):
|
||||
"""The new ValueError handler must not swallow the not-found path:
|
||||
when ``update_job`` returns ``None`` (no such job), the route must
|
||||
still respond 404."""
|
||||
import api.routes as routes
|
||||
|
||||
def _missing(job_id, updates):
|
||||
return None
|
||||
|
||||
_install_cron_jobs_stub(monkeypatch, update_fn=_missing)
|
||||
|
||||
handler = _JSONHandler()
|
||||
routes._handle_cron_update(
|
||||
handler,
|
||||
{"job_id": "ghost", "schedule": "0 9 * * *"},
|
||||
)
|
||||
|
||||
assert handler.status == 404
|
||||
assert "not found" in _payload(handler).get("error", "").lower()
|
||||
|
||||
|
||||
def test_cron_update_valid_schedule_still_succeeds(monkeypatch):
|
||||
"""Happy path: a parseable schedule (the post-fix frontend submits
|
||||
``run_at`` rather than ``schedule_display``) reaches ``update_job``
|
||||
and the route returns 200 with the job payload."""
|
||||
import api.routes as routes
|
||||
|
||||
calls = []
|
||||
job = {
|
||||
"id": "test-job",
|
||||
"schedule": {
|
||||
"kind": "once",
|
||||
"run_at": "2026-08-28T16:05:00-05:00",
|
||||
"display": "once at 2026-08-28 16:05",
|
||||
},
|
||||
"schedule_display": "once at 2026-08-28 16:05",
|
||||
}
|
||||
|
||||
def _ok(job_id, updates):
|
||||
calls.append((job_id, updates))
|
||||
return {**job, **updates}
|
||||
|
||||
_install_cron_jobs_stub(monkeypatch, update_fn=_ok)
|
||||
|
||||
handler = _JSONHandler()
|
||||
routes._handle_cron_update(
|
||||
handler,
|
||||
{
|
||||
"job_id": "test-job",
|
||||
"schedule": "2026-08-28T16:05:00-05:00", # canonical ISO
|
||||
},
|
||||
)
|
||||
|
||||
assert handler.status == 200
|
||||
assert calls and calls[0][0] == "test-job"
|
||||
assert calls[0][1]["schedule"] == "2026-08-28T16:05:00-05:00"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Behavioral round-trip coverage (gate follow-up).
|
||||
#
|
||||
# The assertions above pin the SOURCE TEXT of `_cronScheduleForEdit`. That is
|
||||
# not enough: the first implementation satisfied every one of them while still
|
||||
# rewriting a natural-language recurring schedule. `every monday 9am` has a
|
||||
# canonical `sched.expr` of `0 9 * * 1`, and because `expr` was consulted
|
||||
# before `schedule_display`, opening such a job for edit (or duplicating it)
|
||||
# replaced the user's wording with raw cron. The Agent rebuilds
|
||||
# `schedule_display` from whatever the WebUI submits, so the rewrite sticks and
|
||||
# the user silently loses the phrasing they typed.
|
||||
#
|
||||
# These tests EXECUTE the real helper out of panels.js instead of reading it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def _cron_schedule_for_edit_source() -> str:
|
||||
"""Slice the complete `_cronScheduleForEdit` declaration out of panels.js."""
|
||||
marker = "function _cronScheduleForEdit("
|
||||
start = PANELS_JS.find(marker)
|
||||
assert start != -1, "_cronScheduleForEdit not found in panels.js"
|
||||
paren = PANELS_JS.find("(", start)
|
||||
depth = 0
|
||||
brace = -1
|
||||
for idx in range(paren, len(PANELS_JS)):
|
||||
ch = PANELS_JS[idx]
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
brace = PANELS_JS.find("{", idx)
|
||||
break
|
||||
assert brace != -1, "_cronScheduleForEdit body not found"
|
||||
depth = 0
|
||||
for idx in range(brace, len(PANELS_JS)):
|
||||
ch = PANELS_JS[idx]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return PANELS_JS[start:idx + 1]
|
||||
raise AssertionError("_cronScheduleForEdit body did not terminate")
|
||||
|
||||
|
||||
def _run_cron_schedule_for_edit(job: dict) -> str:
|
||||
"""Execute the real `_cronScheduleForEdit` from panels.js against `job`."""
|
||||
script = (
|
||||
f"{_cron_schedule_for_edit_source()}\n"
|
||||
f"const job = {json.dumps(job)};\n"
|
||||
"process.stdout.write(String(_cronScheduleForEdit(job)));\n"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def test_natural_language_recurring_schedule_survives_edit_unchanged():
|
||||
"""`every monday 9am` must round-trip as typed, not as `0 9 * * 1`.
|
||||
|
||||
Red before the fix: `sched.expr` was preferred over `schedule_display`.
|
||||
"""
|
||||
job = {
|
||||
"schedule_display": "every monday 9am",
|
||||
"schedule": {"kind": "cron", "expr": "0 9 * * 1"},
|
||||
}
|
||||
assert _run_cron_schedule_for_edit(job) == "every monday 9am"
|
||||
|
||||
|
||||
def test_one_shot_still_uses_canonical_run_at_not_the_display_label():
|
||||
"""The original #7352 fix must not regress: `once at ...` is unparseable."""
|
||||
job = {
|
||||
"schedule_display": "once at 2026-08-28 16:00",
|
||||
"schedule": {"kind": "once", "run_at": "2026-08-28T16:00:00"},
|
||||
}
|
||||
assert _run_cron_schedule_for_edit(job) == "2026-08-28T16:00:00"
|
||||
|
||||
|
||||
def test_raw_cron_expression_without_display_falls_back_to_expr():
|
||||
"""A job carrying only `expr` still yields the canonical expression."""
|
||||
job = {"schedule": {"kind": "cron", "expr": "*/15 * * * *"}}
|
||||
assert _run_cron_schedule_for_edit(job) == "*/15 * * * *"
|
||||
|
||||
|
||||
def test_legacy_expression_field_still_accepted():
|
||||
"""Back-compat: older payloads expose `expression` rather than `expr`."""
|
||||
job = {"schedule": {"kind": "cron", "expression": "0 0 * * *"}}
|
||||
assert _run_cron_schedule_for_edit(job) == "0 0 * * *"
|
||||
@@ -0,0 +1,178 @@
|
||||
"""System projects minted by the all-profiles sidebar scan must belong to the
|
||||
profile whose state.db produced the rows, not the profile currently selected.
|
||||
|
||||
`get_cli_sessions(all_profiles=True)` walks every profile's state.db in one
|
||||
request. When it reached a profile with webhook (or cron) sessions, the lazily
|
||||
created "Webhooks" / "Cron Jobs" system project used to be tagged with
|
||||
`get_active_profile_name()` — the UI-selected profile — so a webhook-only
|
||||
profile leaked a "Webhooks" project onto whichever profile the user happened
|
||||
to be viewing. Deleting the stray entry did not help: the next sidebar poll
|
||||
re-minted it, and switching profiles minted another one per profile.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_state_db(path, *, source, count=1):
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT,
|
||||
session_source TEXT,
|
||||
title TEXT,
|
||||
model TEXT,
|
||||
started_at REAL NOT NULL,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
parent_session_id TEXT,
|
||||
ended_at REAL,
|
||||
end_reason TEXT
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT,
|
||||
role TEXT,
|
||||
content TEXT,
|
||||
timestamp REAL
|
||||
);
|
||||
"""
|
||||
)
|
||||
now = time.time()
|
||||
for i in range(count):
|
||||
sid = f"{source}_{i:04d}_{int(now) + i}"
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, source, session_source, title, model,"
|
||||
" started_at, message_count) VALUES (?, ?, ?, ?, 'test-model', ?, 1)",
|
||||
(sid, source, source, f"{source} run", now + i),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO messages (id, session_id, role, content, timestamp)"
|
||||
" VALUES (?, ?, 'user', 'payload', ?)",
|
||||
(f"{source}_msg_{i:04d}", sid, now + i),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_projects(tmp_path, monkeypatch):
|
||||
import api.config as cfg
|
||||
import api.models as models
|
||||
import api.profiles as profiles
|
||||
|
||||
projects_file = tmp_path / "projects.json"
|
||||
monkeypatch.setattr(cfg, "PROJECTS_FILE", projects_file)
|
||||
monkeypatch.setattr(models, "PROJECTS_FILE", projects_file)
|
||||
monkeypatch.setattr(models, "_projects_migrated", True)
|
||||
monkeypatch.setattr(models, "_CRON_PROJECT_LOCK", threading.Lock())
|
||||
monkeypatch.setattr(models, "_WEBHOOK_PROJECT_LOCK", threading.Lock())
|
||||
monkeypatch.setattr(models, "get_last_workspace", lambda *_a, **_kw: tmp_path)
|
||||
monkeypatch.setattr(profiles, "list_profiles_api", lambda: [])
|
||||
monkeypatch.setattr(profiles, "_active_profile", "default")
|
||||
profiles._invalidate_root_profile_cache()
|
||||
yield projects_file
|
||||
profiles._invalidate_root_profile_cache()
|
||||
|
||||
|
||||
def _projects(path):
|
||||
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
|
||||
|
||||
|
||||
def _scan(models, home, db, profile):
|
||||
return models._load_cli_sessions_uncached(
|
||||
home, db, profile,
|
||||
source_filter=None,
|
||||
visible_session_limit=None,
|
||||
cron_project_limit=None,
|
||||
webhook_project_limit=None,
|
||||
kanban_project_limit=None,
|
||||
include_claude_code=False,
|
||||
)
|
||||
|
||||
|
||||
def test_webhook_project_is_tagged_to_scanned_profile_not_selected_one(tmp_path, monkeypatch):
|
||||
import api.models as models
|
||||
import api.profiles as profiles
|
||||
|
||||
projects_file = tmp_path / "projects.json"
|
||||
hook_home = tmp_path / "profiles" / "hooks"
|
||||
hook_home.mkdir(parents=True)
|
||||
hook_db = hook_home / "state.db"
|
||||
_make_state_db(hook_db, source="webhook", count=3)
|
||||
|
||||
# UI has `default` selected; the all-profiles walk reaches the hooks profile.
|
||||
monkeypatch.setattr(profiles, "_active_profile", "default")
|
||||
rows = _scan(models, hook_home, hook_db, "hooks")
|
||||
assert sum(1 for r in rows if r["source_tag"] == "webhook") == 3
|
||||
|
||||
webhook_projects = [p for p in _projects(projects_file) if p["name"] == models.WEBHOOK_PROJECT_NAME]
|
||||
assert [p["profile"] for p in webhook_projects] == ["hooks"], (
|
||||
"Webhooks project must be tagged to the profile that owns the sessions"
|
||||
)
|
||||
assert all(r["project_id"] == webhook_projects[0]["project_id"]
|
||||
for r in rows if r["source_tag"] == "webhook")
|
||||
|
||||
# Switching the selected profile and rescanning must reuse the same project
|
||||
# rather than minting one per selected profile.
|
||||
monkeypatch.setattr(profiles, "_active_profile", "deepseek")
|
||||
_scan(models, hook_home, hook_db, "hooks")
|
||||
webhook_projects = [p for p in _projects(projects_file) if p["name"] == models.WEBHOOK_PROJECT_NAME]
|
||||
assert [p["profile"] for p in webhook_projects] == ["hooks"]
|
||||
|
||||
|
||||
def test_scanning_a_profile_without_webhook_rows_mints_nothing(tmp_path, monkeypatch):
|
||||
import api.models as models
|
||||
import api.profiles as profiles
|
||||
|
||||
projects_file = tmp_path / "projects.json"
|
||||
home = tmp_path / "profiles" / "quiet"
|
||||
home.mkdir(parents=True)
|
||||
db = home / "state.db"
|
||||
_make_state_db(db, source="cli", count=2)
|
||||
|
||||
monkeypatch.setattr(profiles, "_active_profile", "default")
|
||||
_scan(models, home, db, "quiet")
|
||||
_scan(models, home, db, "default")
|
||||
|
||||
assert not [p for p in _projects(projects_file) if p["name"] == models.WEBHOOK_PROJECT_NAME]
|
||||
|
||||
|
||||
def test_cron_project_gate_and_tag_follow_scanned_profile(tmp_path, monkeypatch):
|
||||
"""The opt-in gate and the tag for Cron Jobs must both key on the scanned
|
||||
profile: a user project on the *selected* profile must not unlock cron
|
||||
project creation for a different scanned profile."""
|
||||
import api.models as models
|
||||
import api.profiles as profiles
|
||||
|
||||
projects_file = tmp_path / "projects.json"
|
||||
projects_file.write_text(json.dumps([
|
||||
{"project_id": "u1", "name": "Mine", "profile": "default", "created_at": 1.0},
|
||||
]), encoding="utf-8")
|
||||
|
||||
cron_home = tmp_path / "profiles" / "jobs"
|
||||
cron_home.mkdir(parents=True)
|
||||
cron_db = cron_home / "state.db"
|
||||
_make_state_db(cron_db, source="cron", count=1)
|
||||
|
||||
monkeypatch.setattr(profiles, "_active_profile", "default")
|
||||
rows = _scan(models, cron_home, cron_db, "jobs")
|
||||
cron_rows = [r for r in rows if r["source_tag"] == "cron"]
|
||||
assert len(cron_rows) == 1
|
||||
assert cron_rows[0]["project_id"] is None
|
||||
assert not [p for p in _projects(projects_file) if p["name"] == models.CRON_PROJECT_NAME]
|
||||
|
||||
# Once the scanned profile itself opts in, its cron project is tagged to it.
|
||||
projects_file.write_text(json.dumps([
|
||||
{"project_id": "u1", "name": "Mine", "profile": "default", "created_at": 1.0},
|
||||
{"project_id": "u2", "name": "Theirs", "profile": "jobs", "created_at": 2.0},
|
||||
]), encoding="utf-8")
|
||||
rows = _scan(models, cron_home, cron_db, "jobs")
|
||||
cron_projects = [p for p in _projects(projects_file) if p["name"] == models.CRON_PROJECT_NAME]
|
||||
assert [p["profile"] for p in cron_projects] == ["jobs"]
|
||||
assert rows[0]["project_id"] == cron_projects[0]["project_id"]
|
||||
@@ -242,7 +242,7 @@ def test_load_cli_sessions_uncached_pushes_specific_source_into_state_db_scan(mo
|
||||
monkeypatch.setattr(models, "get_claude_code_sessions", lambda: claude_calls.append(True) or [])
|
||||
monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows)
|
||||
monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path)
|
||||
monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False)
|
||||
monkeypatch.setattr(models, "_profile_has_user_projects", lambda *_a, **_kw: False)
|
||||
monkeypatch.setattr(models, "ensure_cron_project", lambda **_: "cron-project-id")
|
||||
monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None)
|
||||
|
||||
@@ -284,7 +284,7 @@ def test_cron_source_filter_uses_cron_rescue_limit(monkeypatch, tmp_path):
|
||||
|
||||
monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows)
|
||||
monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path)
|
||||
monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False)
|
||||
monkeypatch.setattr(models, "_profile_has_user_projects", lambda *_a, **_kw: False)
|
||||
monkeypatch.setattr(models, "ensure_cron_project", lambda **_: "cron-project-id")
|
||||
monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None)
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ def test_cron_state_projection_preserves_archived_sidecar(monkeypatch, tmp_path)
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False)
|
||||
monkeypatch.setattr(models, "_profile_has_user_projects", lambda *_a, **_kw: False)
|
||||
monkeypatch.setattr(models, "ensure_cron_project", lambda **_: "cron-project")
|
||||
models.clear_sidecar_metadata_cache()
|
||||
|
||||
|
||||
@@ -109,8 +109,8 @@ def test_webhook_rows_get_webhook_project_id(monkeypatch, tmp_path):
|
||||
|
||||
monkeypatch.setattr(models, "read_importable_agent_session_rows", lambda *_a, **_kw: [_agent_row()])
|
||||
monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path)
|
||||
monkeypatch.setattr(models, "ensure_cron_project", lambda: "cron-project-id")
|
||||
monkeypatch.setattr(models, "ensure_webhook_project", lambda: "webhook-project-id", raising=False)
|
||||
monkeypatch.setattr(models, "ensure_cron_project", lambda **_kw: "cron-project-id")
|
||||
monkeypatch.setattr(models, "ensure_webhook_project", lambda **_kw: "webhook-project-id", raising=False)
|
||||
monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None)
|
||||
|
||||
rows = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None, cron_project_limit=False)
|
||||
@@ -138,8 +138,8 @@ def test_webhook_second_pass_keeps_older_project_rows_available(monkeypatch, tmp
|
||||
|
||||
monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows)
|
||||
monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path)
|
||||
monkeypatch.setattr(models, "ensure_cron_project", lambda: "cron-project-id")
|
||||
monkeypatch.setattr(models, "ensure_webhook_project", lambda: "webhook-project-id", raising=False)
|
||||
monkeypatch.setattr(models, "ensure_cron_project", lambda **_kw: "cron-project-id")
|
||||
monkeypatch.setattr(models, "ensure_webhook_project", lambda **_kw: "webhook-project-id", raising=False)
|
||||
monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None)
|
||||
|
||||
rows = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None, cron_project_limit=False)
|
||||
|
||||
Reference in new issue
Block a user