diff --git a/CHANGELOG.md b/CHANGELOG.md index f8da5d0..828c231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ Postmaster MCP follows Semantic Versioning for stable releases. Every stable release should update `VERSION`, this changelog, and publish an immutable Git tag/release named `vX.Y.Z`. +## 9.4.4 - 2026-08-20 + +### Fixed +- Restored the public task MCP/API contract to v9.4.2 compatibility. `list_jobs(owner_id=None, project_id=None, status=None, limit=200)` again has no `include_completed` parameter, includes `status=completed` records by default, and uses the pre-v9.4.3 MCP serialization without the `{ok, count, jobs}` wrapper. +- Restored `SchedulerEngine.list_jobs` to the v9.4.2 signature and semantics. Completed-task visibility is no longer a scheduler/backend default and explicit owner/project/status filters behave as they did in v9.4.2. +- Removed the duplicate runtime `get_job` registration that could log `Tool already exists: get_job`. The original read-only `get_job(job_id)` tool from v9.4.2 remains the single canonical registration. +- Removed the v9.4.3 `build_status.task_detail_view` and `build_status.completed_tasks_hidden_by_default` capability flags, because completed visibility/detail is now strictly a WebGUI presentation concern. + +### Added +- WebGUI-only Tasks filtering: completed tasks remain in the database and MCP results, but the Tasks page hides them by default and offers `Show completed (N)` / `Hide completed` controls. +- A `View` action for every displayed task and a read-only task-detail panel showing id, owner/project, title/description, action type, execution profile, schedule, timezone, approval mode, status, timestamps, last error and safely rendered payload. +- Dashboard-local `show_completed=1` and `view_job=` navigation that preserves the Tasks tab. A completed task can be opened directly by ID even while completed rows remain hidden from the default list. +- Regression coverage for the v9.4.2 MCP schema/output contract, completed visibility in MCP versus WebGUI, single `get_job` registration, safe detail rendering, counts, Pause/Resume, scheduler status, due jobs, completion and recurring advancement. + +### Compatibility / deployment +- `create_job`, `update_job`, `pause_job`, `resume_job`, `complete_job`, `delete_job`, `get_job_history`, `list_due_jobs`, `scheduler_status`, recurrence advancement, approval/security and persistent task storage are unchanged. +- No scheduler/database migration, environment variable, port, volume or Cloudflare rule is introduced. +- `postmaster-mcp.yml` remains byte-for-byte unchanged. Existing `POSTMASTER_VERSION=latest` deployments with update checks enabled can select v9.4.4 with the normal restart/redeploy after the stable release is published. + ## 9.4.3 - 2026-08-20 ### Added @@ -159,4 +178,4 @@ Postmaster MCP follows Semantic Versioning for stable releases. Every stable rel - CI coverage for runtime import, bootstrap, model provisioning, MIME regressions and knowledge operations. ### Changed -- Public project naming and configuration became provider-agnostic. +- Public project naming and configuration became provider-agnostic. \ No newline at end of file diff --git a/README.md b/README.md index af05805..d796d3c 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ The important v9 changes are: - improved MIME parsing for forwarded mail and HTML-heavy messages; - CI coverage for the bootstrap, MIME parser, knowledge store and semantic-model provisioning; - persistent small-file storage plus native ChatGPT file inputs in v9.2; -- task list/detail UX with completed tasks hidden by default and explicit retrieval in v9.4.3; +- WebGUI-only task list/detail UX in v9.4.4, while the public task MCP/API contract is restored to v9.4.2 compatibility; - semantic release history through `VERSION`, `CHANGELOG.md` and immutable `vX.Y.Z` release tags. --- @@ -450,18 +450,18 @@ Review Junk and restore genuine false positives. Check unread mail and summarize messages requiring attention. ``` -From v9.4.3 the task read UX mirrors the Memory/Skill list/detail pattern. Completed tasks remain stored with the persistent status `completed`, but they are hidden from the normal list unless the caller asks for them explicitly: +v9.4.4 deliberately restores the public task MCP/API behavior to the v9.4.2 contract: ```text -list_jobs() -> non-completed tasks only -list_jobs(include_completed=true) -> non-completed + completed tasks -list_jobs(status="completed") -> completed tasks explicitly -get_job(job_id) -> complete record for one task +list_jobs(owner_id=None, project_id=None, status=None, limit=200) +get_job(job_id) ``` -`list_jobs` returns one structured MCP result with `{ok, count, jobs}`. The individual job objects keep their existing fields for compatibility, while `get_job` is the full detail view with owner/project, description, action type, execution profile, schedule, approval mode, status, timestamps, last error and payload. Owner/project/status filters still combine normally, and the result `limit` is applied after completed tasks are excluded. +There is no `include_completed` parameter. `list_jobs()` includes completed tasks by default, an explicit `status="completed"` filter still selects completed records, and the MCP output uses the same pre-v9.4.3 serialization rather than a `{ok, count, jobs}` wrapper. `get_job` is not new in v9.4.4; it is the original read-only v9.4.2 task lookup and is registered only once. -Hiding a completed task is presentation only: no record is renamed to `done`, archived, deleted or migrated. `scheduler_status` still counts completed tasks, and `get_job` can read a completed task directly by ID. The server persists the task state; the AI client performs the reasoning and explicit action. +The completed-task UX now exists **only in the WebGUI**. On the Tasks page, rows with the persistent status `completed` are hidden by default, `Show completed (N)` reveals them, and `Hide completed` returns to the default view. Each displayed task has a `View` action, and the detail view shows the complete stored task record including owner/project, description, action type, execution profile, schedule, approval mode, status, timestamps, last error and safely rendered payload. A completed task can be opened directly with the dashboard-local `view_job=` query even while completed rows are hidden from the list. + +Hiding a completed task is presentation only: no record is renamed to `done`, archived, deleted or migrated. `scheduler_status` still counts completed tasks, MCP callers still receive them from the default `list_jobs()`, and the server remains a passive task registry; the AI client performs the reasoning and explicit action. --- diff --git a/VERSION b/VERSION index 3200162..86a1d29 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -9.4.3 +9.4.4 diff --git a/src/postmaster/runtime.py b/src/postmaster/runtime.py index 0e645fa..11edef1 100644 --- a/src/postmaster/runtime.py +++ b/src/postmaster/runtime.py @@ -1,390 +1,278 @@ from __future__ import annotations +import json import os from html import escape -from typing import Any +from urllib.parse import urlencode import uvicorn -from mcp.types import CallToolResult from starlette.requests import Request -from starlette.responses import HTMLResponse, PlainTextResponse, RedirectResponse -from starlette.routing import Mount, Route - -from . import server as _base -from .file_handoff import ( - read_stored_file_resource, - stored_file_http_response, - stored_file_resource_result, -) -from .link_tracking import link_store -from .link_tracking_html import eligible_web_url -from .tracked_mail import LinkTrackingMailClient - -mcp = _base.mcp -_legacy_build_status = _base.build_status -_legacy_tracking_status = _base.tracking_status -_legacy_get_tracking_campaign = _base.get_tracking_campaign -_legacy_dashboard_home = _base.dashboard_home - - -def mail_client(account_id: str | None = None) -> LinkTrackingMailClient: - return LinkTrackingMailClient(_base.account_store().settings(account_id)) - -_base.mail_client = mail_client - - -def build_status(): - status = _legacy_build_status() - status["native_file_resource_handoff"] = True - status["link_tracking"] = True - status["sent_copy_tracking_sanitized"] = True - status["provider_qualitative_classification"] = True - status["explicit_reply_follow_up_modes"] = True - status["follow_up_email"] = True - status["follow_up_draft"] = True - status["task_detail_view"] = True - status["completed_tasks_hidden_by_default"] = True - return status - -mcp.remove_tool("build_status") -mcp.add_tool(build_status, name="build_status") -_base.build_status = build_status +from starlette.responses import HTMLResponse +from starlette.routing import Route +from . import runtime_core as _core -def list_jobs( - owner_id: str | None = None, - project_id: str | None = None, - status: str | None = None, - limit: int = 200, - include_completed: bool = False, -): - """Read-only. List registered tasks. Completed tasks are hidden unless explicitly requested.""" - rows = _base._safe_call( - _base.scheduler().list_jobs, - owner_id=owner_id, - project_id=project_id, - status=status, - limit=limit, - include_completed=include_completed, - ) - if isinstance(rows, dict) and rows.get("ok") is False: - return {"ok": False, "error": rows.get("error", "Unable to list jobs"), "count": 0, "jobs": []} - return {"ok": True, "count": len(rows), "jobs": rows} - -mcp.remove_tool("list_jobs") -mcp.add_tool(list_jobs, name="list_jobs") -_base.list_jobs = list_jobs - - -@mcp.tool() -def get_job(job_id: str): - """Read-only. Return the complete stored record for one registered task.""" - return _base._safe_call(_base.scheduler().get_job, job_id) - - -@mcp.tool() -def follow_up_email( - mailbox: str, - uid: str, - body: str = "", - cc: list[str] | None = None, - bcc: list[str] | None = None, - body_html: str | None = None, - attachments: list[dict[str, Any]] | None = None, - track_opens: bool | None = None, - campaign_id: str | None = None, - account_id: str | None = None, -): - """ - WRITE ACTION. Threaded follow-up to an outbound/Sent message from the selected account. - - The original visible To/Cc recipients are reused after removing the sender account and - configured account identities. Original Bcc is never rediscovered or exposed. Inbound - messages are rejected and should use reply_email instead. - - Tracking follows the same account-default/explicit override semantics and the same v9.4 - recipient/Sent-clean pipeline as send_email and reply_email. - """ - return _base._safe_call( - mail_client(account_id).follow_up_email, - mailbox=mailbox, uid=uid, body=body, cc=cc, bcc=bcc, - body_html=body_html, attachments=attachments, - track_opens=track_opens, campaign_id=campaign_id, - ) - - -@mcp.tool() -def create_follow_up_draft( - mailbox: str, - uid: str, - body: str = "", - cc: list[str] | None = None, - bcc: list[str] | None = None, - body_html: str | None = None, - attachments: list[dict[str, Any]] | None = None, - account_id: str | None = None, -): - """ - WRITE ACTION. Save a threaded follow-up draft for an outbound/Sent message. - - The draft reuses the original visible To/Cc after sender/alias filtering, never recovers - original Bcc, and rejects inbound messages so reply/follow-up semantics stay explicit. - """ - return _base._safe_call( - mail_client(account_id).create_follow_up_draft, - mailbox=mailbox, uid=uid, body=body, cc=cc, bcc=bcc, - body_html=body_html, attachments=attachments, - ) - - -@mcp.tool() -def get_stored_file_resource(file_id: str, transport: str = "auto") -> CallToolResult: - return stored_file_resource_result(_base.file_store(), file_id, transport) - - -@mcp.resource( - "postmaster://files/{file_id}", - name="postmaster_stored_file", - description="Original bytes for a Postmaster FileStore file identified by canonical file_id.", +# v9.4.4 keeps the v9.4.2 task MCP/backend contract in runtime_core and layers +# completed-task visibility/detail only onto the browser dashboard. +for _name in dir(_core): + if _name.startswith("_"): + continue + globals()[_name] = getattr(_core, _name) + +_base = _core._base +_tracking_dashboard_fragment = _core._tracking_dashboard_fragment +_legacy_dashboard_home = _core.dashboard_home +mcp = _core.mcp +app = _core.app + +_TASK_DETAIL_FIELDS = ( + "id", + "owner_id", + "project_id", + "title", + "description", + "action_type", + "execution_profile_id", + "schedule_type", + "schedule_value", + "timezone", + "approval_mode", + "status", + "next_run_utc", + "created_at", + "updated_at", + "last_run_utc", + "last_error", + "payload", ) -def stored_file_resource(file_id: str) -> bytes: - return read_stored_file_resource(_base.file_store(), file_id) -def tracking_status(): - base = _legacy_tracking_status() - if isinstance(base, dict) and base.get("ok"): - base["link_tracking"] = link_store().status() - base["link_tracking"]["provider_classification_query_time"] = True - base["event_types"] = ["pixel", "amp_xhr", "link"] - return base - -mcp.remove_tool("tracking_status") -mcp.add_tool(tracking_status, name="tracking_status") -_base.tracking_status = tracking_status - +def _task_dashboard_url( + request: Request, + *, + show_completed: bool, + view_job: str | None = None, +) -> str: + params: dict[str, str] = {} + account_id = (request.query_params.get("account") or "").strip() + if account_id: + params["account"] = account_id + if show_completed: + params["show_completed"] = "1" + if view_job: + params["view_job"] = view_job + query = urlencode(params) + return "/" + (("?" + query) if query else "") + "#scheduler" + + +def _task_detail_html(request: Request, *, show_completed: bool) -> str: + job_id = (request.query_params.get("view_job") or "").strip() + if not job_id: + return "" + + detail = _base._safe_call(_base.scheduler().get_job, job_id) + close_url = escape( + _task_dashboard_url(request, show_completed=show_completed), + quote=True, + ) + if not isinstance(detail, dict) or detail.get("ok") is False: + message = "Task could not be loaded" + if isinstance(detail, dict): + message = str(detail.get("error") or message) + return f""" +
+

Task detail

+
{escape(message)}
+
+""" -def get_tracking_campaign(campaign_id: str): - base = _legacy_get_tracking_campaign(campaign_id) - if isinstance(base, dict) and base.get("ok") is False: - return base - try: - base["link_tracking"] = link_store().summary(campaign_id=campaign_id) - base["top_links"] = link_store().top_links(campaign_id=campaign_id, limit=25) - except Exception as exc: - base["link_tracking_error"] = f"{type(exc).__name__}: {exc}" - return base + rows: list[str] = [] + for field in _TASK_DETAIL_FIELDS: + value = detail.get(field) + if field == "payload": + try: + rendered = json.dumps( + value if value is not None else {}, + ensure_ascii=False, + indent=2, + sort_keys=True, + default=str, + ) + except Exception: + rendered = str(value) + cell = ( + '
'
+                + escape(rendered)
+                + "
" + ) + elif value is None or value == "": + cell = '' + elif field == "description": + cell = ( + '
' + + escape(str(value)) + + "
" + ) + else: + cell = '' + escape(str(value)) + "" + rows.append(f"{escape(field)}{cell}") -mcp.remove_tool("get_tracking_campaign") -mcp.add_tool(get_tracking_campaign, name="get_tracking_campaign") -_base.get_tracking_campaign = get_tracking_campaign + return f""" +
+

Task detail

+
{''.join(rows)}
+
+""" -@mcp.tool() -def get_tracking_summary( - campaign_id: str | None = None, - delivery_id: str | None = None, - link_id: str | None = None, - account_id: str | None = None, -): - """Read-only click summary with stable fingerprint uniques plus query-time provider estimates.""" - return _base._safe_call( - link_store().summary, - campaign_id=campaign_id, delivery_id=delivery_id, link_id=link_id, account_id=account_id, +def _task_dashboard_fragment(request: Request) -> str: + show_completed = request.query_params.get("show_completed") == "1" + listed = _base._safe_call(_base.scheduler().list_jobs, limit=1000) + all_jobs = listed if isinstance(listed, list) else [] + visible_jobs = ( + all_jobs + if show_completed + else [job for job in all_jobs if str(job.get("status") or "") != "completed"] ) - -@mcp.tool() -def list_tracking_links( - campaign_id: str | None = None, - delivery_id: str | None = None, - link_id: str | None = None, - account_id: str | None = None, - clicked_only: bool = False, - limit: int = 500, -): - """Read-only tracked link occurrences and aggregates; opaque tokens are never returned.""" - return _base._safe_call( - link_store().list_links, - campaign_id=campaign_id, delivery_id=delivery_id, link_id=link_id, - account_id=account_id, clicked_only=clicked_only, limit=limit, + status_result = _base._safe_call(_base.scheduler().status) + job_counts = ( + status_result.get("job_counts") or {} + if isinstance(status_result, dict) + else {} ) + completed_count = int(job_counts.get("completed") or 0) + stored_count = sum(int(value or 0) for value in job_counts.values()) + if stored_count == 0 and all_jobs: + stored_count = len(all_jobs) + completed_count = sum( + 1 for job in all_jobs if str(job.get("status") or "") == "completed" + ) + due_result = _base._safe_call(_base.scheduler().list_due_jobs, limit=1000) + due_count = len(due_result) if isinstance(due_result, list) else 0 -@mcp.tool() -def list_tracking_events( - delivery_id: str | None = None, - campaign_id: str | None = None, - link_id: str | None = None, - recipient: str | None = None, - account_id: str | None = None, - event_type: str | None = None, - limit: int = 500, -): - """Read-only unified tracking events. Link rows include query-time provider classification fields.""" - return _base._safe_call( - link_store().unified_events, - delivery_id=delivery_id, campaign_id=campaign_id, link_id=link_id, - recipient=recipient, account_id=account_id, event_type=event_type, limit=limit, + toggle_url = escape( + _task_dashboard_url(request, show_completed=not show_completed), + quote=True, ) - - -async def public_stored_file_download(request: Request): - return stored_file_http_response(request, _base.file_store(), require_signature=True) - - -async def dashboard_file_download(request: Request): - return stored_file_http_response(request, _base.file_store(), require_signature=False) - - -async def tracking_click(request: Request): - token = str(request.path_params.get("token", "")) - try: - link = link_store().get_by_token(token) - destination = str(link.get("original_url") or "") - if not eligible_web_url(destination): - raise ValueError("Stored link destination is not an HTTP/HTTPS URL") - except Exception: - return PlainTextResponse("Not found", status_code=404, headers={"Cache-Control": "no-store"}) - - forwarded = request.headers.get("x-forwarded-for", "") - client_ip = forwarded.split(",", 1)[0].strip() if forwarded else "" - if not client_ip and request.client: - client_ip = request.client.host or "" - try: - link_store().record_click( - link, - user_agent=request.headers.get("user-agent", ""), - client_ip=client_ip, - country_code=request.headers.get("cf-ipcountry", ""), + toggle_label = "Hide completed" if show_completed else f"Show completed ({completed_count})" + if show_completed: + count_text = ( + f"{len(visible_jobs)} shown · {completed_count} completed · " + f"{due_count} due · {stored_count} stored" ) - except Exception: - _base.logger.info("Link click could not be recorded", exc_info=True) - - response = RedirectResponse(destination, status_code=302) - response.headers["Cache-Control"] = "private, no-store, no-cache, max-age=0" - response.headers["Pragma"] = "no-cache" - return response - - -def _tracking_dashboard_fragment(account_id: str | None = None) -> str: - summary = link_store().summary(account_id=account_id) - qualitative = summary.get("qualitative_estimate") or {} - share = qualitative.get("potential_provider_share") or {} - top = link_store().top_links(account_id=account_id, limit=20) - events = link_store().unified_events(account_id=account_id, limit=100) - top_rows = [] - for row in top: - label = str(row.get("anchor_text") or row.get("destination_host") or row.get("original_url") or "") - top_rows.append( - "" - f"{escape(str(row.get('link_id','')))}" - f"{escape(label)}" - f"{escape(str(row.get('destination_host','')))}" - f"{int(row.get('total_clicks') or 0)}" - f"{int(row.get('unique_clicks') or 0)}" - f"{int(row.get('unique_recipients') or 0)}" - f"{escape(str(row.get('first_click') or ''))}" - f"{escape(str(row.get('last_click') or ''))}" + else: + count_text = ( + f"{len(visible_jobs)} shown · {completed_count} completed hidden · " + f"{due_count} due · {stored_count} stored" ) - if not top_rows: - top_rows.append('No link clicks recorded yet.') - event_rows = [] - for row in events: - source = " / ".join(x for x in (str(row.get("country_code") or ""), str(row.get("client_source") or "")) if x) - browser_os = " / ".join(x for x in (str(row.get("browser") or ""), str(row.get("os") or "")) if x) - label = str(row.get("anchor_text") or row.get("destination_host") or "") - ua = str(row.get("user_agent") or "")[:180] - provider_class = str(row.get("provider_classification") or "") - provider_likelihood = str(row.get("provider_likelihood") if row.get("provider_likelihood") is not None else "") - provider_guess = str(row.get("provider_guess") or "") - reasons = "; ".join(str(x) for x in (row.get("classification_reasons") or [])) - event_rows.append( - "" - f"{escape(str(row.get('event_type') or ''))}" - f"{escape(str(row.get('recipient') or ''))}" - f"{escape(str(row.get('observed_at') or ''))}" - f"{escape(source)}{escape(browser_os)}" - f"{escape(str(row.get('campaign_id') or ''))}
{escape(str(row.get('delivery_id') or ''))}" - f"{escape(str(row.get('client_fingerprint') or ''))}" - f"{escape(provider_likelihood)}{escape(provider_class)}{escape(provider_guess)}" - f"{escape(reasons[:180])}" - f"{escape(label)}{escape(str(row.get('link_id') or ''))}" - f"{escape(str(row.get('destination_host') or ''))}" - f"{escape(str(row.get('position') if row.get('position') is not None else ''))}" - f"{escape(ua)}" + job_rows: list[str] = [] + for job in visible_jobs: + raw_id = str(job.get("id") or "") + job_id = escape(raw_id) + status = escape(str(job.get("status") or "")) + title = escape(str(job.get("title") or "")) + owner = escape(str(job.get("owner_id") or "")) + project = escape(str(job.get("project_id") or "")) + action = escape(str(job.get("action_type") or "")) + next_run = escape(str(job.get("next_run_utc") or "—")) + payload = job.get("payload") or {} + account_ref = ( + escape(str(payload.get("account_id") or "")) + if isinstance(payload, dict) + else "" + ) + view_url = escape( + _task_dashboard_url( + request, + show_completed=show_completed, + view_job=raw_id, + ), + quote=True, + ) + buttons = f'' + if status == "paused": + buttons += f"""
+ +
""" + elif status != "completed": + buttons += f"""
+ +
""" + account_note = ( + f'
account ref: {account_ref}
' + if account_ref + else "" + ) + job_rows.append( + f""" +{title}
{job_id}
+{owner}
{project}{account_note} +{action}{status}{next_run} +{buttons}""" ) - if not event_rows: - event_rows.append('No tracking events recorded yet.') - - unique_clicks = int(summary.get("unique_clicks") or 0) - likely_provider = int(qualitative.get("likely_provider_unique_clicks") or 0) - human_or_unclassified = int(qualitative.get("likely_human_or_unclassified_unique_clicks") or 0) - uncertain = int(qualitative.get("uncertain_unique_clicks") or 0) - share_percent = float(share.get("percent") or 0.0) - suspects = qualitative.get("provider_suspects") or {} - suspect_text = ", ".join(f"{key}: {value}" for key, value in suspects.items()) or "none" + detail_html = _task_detail_html(request, show_completed=show_completed) + empty_text = ( + "No tasks registered" + if show_completed + else "No non-completed tasks registered" + ) return f""" +
+
+{detail_html}
-

Qualitative click estimate

v9.4.1 query-time heuristic
-

Stable fingerprint uniques remain unchanged. Provider likelihood is recalculated from stored evidence and never deletes or rewrites raw events.

-

{unique_clicks} unique fingerprint clicks  ·  {likely_provider} likely provider/proxy  ·  {human_or_unclassified} likely human or unclassified  ·  {uncertain} uncertain

-

Potential provider share: {likely_provider}/{unique_clicks} ({share_percent:.1f}%). Provider suspects: {escape(suspect_text)}. Confidence: {escape(str(qualitative.get('confidence') or 'low'))}.

+

Task registry

{escape(count_text)}
+

No cron worker runs here. Dates and recurrence are stored only so an AI or user can query what is due. Tasks never send email or execute actions by themselves. Completed tasks remain stored; the default hiding on this page is WebGUI-only.

+
+{''.join(job_rows) or f''}
TaskOwner / projectTypeStatusDue / next UTC
{escape(empty_text)}
-
-

Top links

v9.4 click analytics
-

Unique click = delivery_id + link_id + client_fingerprint. The qualitative classifier is an additive interpretation layer and does not change these totals.

-
{''.join(top_rows)}
Link IDLabelDestination hostTotalUniqueRecipientsFirst clickLast click
-
-
-

Tracking events

pixel / AMP / link
-
{''.join(event_rows)}
TypeRecipientObserved UTCCountry / sourceBrowser / OSCampaign / deliveryClient fingerprintProvider %ClassificationProvider guessReasonsLink labelLink IDDestinationPositionUser-Agent
+
""" +def _replace_task_dashboard(body: str, fragment: str) -> str: + start_marker = '
' + end_marker = "\n", "nested": {"value": 7}}, + ) + self._mark_completed(completed["id"]) + + with patch.object(runtime._base, "scheduler", return_value=self.engine): + default_html = runtime._task_dashboard_fragment(self._request()) + self.assertIn("Active <unsafe>", default_html) + self.assertIn("Paused task", default_html) + self.assertNotIn("Completed detail", default_html) + self.assertIn("Show completed (1)", default_html) + self.assertIn("1 completed hidden", default_html) + self.assertIn("3 stored", default_html) + self.assertIn("/dashboard/job/pause", default_html) + self.assertIn("/dashboard/job/resume", default_html) + self.assertIn(">View<", default_html) + + shown_html = runtime._task_dashboard_fragment( + self._request("show_completed=1") + ) + self.assertIn("Completed detail", shown_html) + self.assertIn("Hide completed", shown_html) + self.assertIn("1 completed", shown_html) + self.assertNotIn("completed hidden", shown_html) + + detail_html = runtime._task_dashboard_fragment( + self._request(f"view_job={completed['id']}") + ) + self.assertIn("Task detail", detail_html) + self.assertIn("Completed detail", detail_html) + self.assertIn("Show completed (1)", detail_html) + for field in ( + "id", "owner_id", "project_id", "title", "description", + "action_type", "execution_profile_id", "schedule_type", + "schedule_value", "timezone", "approval_mode", "status", + "next_run_utc", "created_at", "updated_at", "last_run_utc", + "last_error", "payload", + ): + self.assertIn(f"{field}", detail_html) + self.assertNotIn("", detail_html) + self.assertIn("<script>alert(1)</script>", detail_html) + self.assertNotIn('', detail_html) + self.assertIn("<img src=x onerror="alert(1)">", detail_html) + + self.assertEqual(self.engine.get_job(active["id"])["status"], "scheduled") + self.assertEqual(self.engine.get_job(completed["id"])["status"], "completed") + + def test_pause_resume_due_complete_recurring_and_status_do_not_regress(self) -> None: + paused = self._create_job(title="Pause regression") + paused_state = self.engine.pause_job(paused["id"]) + self.assertEqual(paused_state["status"], "paused") + resumed_state = self.engine.resume_job(paused["id"]) + self.assertEqual(resumed_state["status"], "scheduled") + + once = self._create_job( + title="Due once", + schedule_type="once", + schedule_value=self._future_once(), + payload={"source": "create-regression"}, + ) + self._force_due(once["id"]) + self.assertIn(once["id"], [row["id"] for row in self.engine.list_due_jobs()]) + once_done = self.engine.complete_job(once["id"], note="once complete") + self.assertEqual(once_done["status"], "completed") + self.assertIsNone(once_done["next_run_utc"]) + self.assertIn(once["id"], [row["id"] for row in self.engine.list_jobs()]) + self.assertEqual(self.engine.status()["job_counts"].get("completed"), 1) + self.assertNotIn(once["id"], [row["id"] for row in self.engine.list_due_jobs()]) + + recurring = self._create_job( + title="Recurring", + schedule_type="interval", + schedule_value="3600", + ) + self._force_due(recurring["id"]) + recurring_done = self.engine.complete_job(recurring["id"], note="advance") + self.assertEqual(recurring_done["status"], "scheduled") + self.assertIsNotNone(recurring_done["last_run_utc"]) + self.assertGreater( + datetime.fromisoformat(recurring_done["next_run_utc"]), + datetime.now(timezone.utc), + ) + + +if __name__ == "__main__": + unittest.main()