diff --git a/packages/overlays/matrix-synapse/patches/0023-Add-an-Admin-API-endpoint-to-fetch-scheduled-tasks-1.patch b/packages/overlays/matrix-synapse/patches/0023-Add-an-Admin-API-endpoint-to-fetch-scheduled-tasks-1.patch
new file mode 100644
index 0000000..c874ee0
--- /dev/null
+++ b/packages/overlays/matrix-synapse/patches/0023-Add-an-Admin-API-endpoint-to-fetch-scheduled-tasks-1.patch
@@ -0,0 +1,383 @@
+From 6dc1ecd35972c95ce62c5e0563245845c9c64e49 Mon Sep 17 00:00:00 2001
+From: Shay <hillerys@element.io>
+Date: Thu, 1 May 2025 11:30:00 -0700
+Subject: [PATCH 23/74] Add an Admin API endpoint to fetch scheduled tasks
+ (#18214)
+
+---
+ changelog.d/18214.feature | 1 +
+ docs/admin_api/scheduled_tasks.md | 54 +++++++
+ synapse/rest/admin/__init__.py | 2 +
+ synapse/rest/admin/scheduled_tasks.py | 70 +++++++++
+ tests/rest/admin/test_scheduled_tasks.py | 192 +++++++++++++++++++++++
+ 5 files changed, 319 insertions(+)
+ create mode 100644 changelog.d/18214.feature
+ create mode 100644 docs/admin_api/scheduled_tasks.md
+ create mode 100644 synapse/rest/admin/scheduled_tasks.py
+ create mode 100644 tests/rest/admin/test_scheduled_tasks.py
+
+diff --git a/changelog.d/18214.feature b/changelog.d/18214.feature
+new file mode 100644
+index 0000000000..751cb7d383
+--- /dev/null
++++ b/changelog.d/18214.feature
+@@ -0,0 +1 @@
++Add an Admin API endpoint `GET /_synapse/admin/v1/scheduled_tasks` to fetch scheduled tasks.
+\ No newline at end of file
+diff --git a/docs/admin_api/scheduled_tasks.md b/docs/admin_api/scheduled_tasks.md
+new file mode 100644
+index 0000000000..1708871a6d
+--- /dev/null
++++ b/docs/admin_api/scheduled_tasks.md
+@@ -0,0 +1,54 @@
++# Show scheduled tasks
++
++This API returns information about scheduled tasks.
++
++To use it, you will need to authenticate by providing an `access_token`
++for a server admin: see [Admin API](../usage/administration/admin_api/).
++
++The api is:
++```
++GET /_synapse/admin/v1/scheduled_tasks
++```
++
++It returns a JSON body like the following:
++
++```json
++{
++ "scheduled_tasks": [
++ {
++ "id": "GSA124oegf1",
++ "action": "shutdown_room",
++ "status": "complete",
++ "timestamp": 23423523,
++ "resource_id": "!roomid",
++ "result": "some result",
++ "error": null
++ }
++ ]
++}
++```
++
++**Query parameters:**
++
++* `action_name`: string - Is optional. Returns only the scheduled tasks with the given action name.
++* `resource_id`: string - Is optional. Returns only the scheduled tasks with the given resource id.
++* `status`: string - Is optional. Returns only the scheduled tasks matching the given status, one of
++ - "scheduled" - Task is scheduled but not active
++ - "active" - Task is active and probably running, and if not will be run on next scheduler loop run
++ - "complete" - Task has completed successfully
++ - "failed" - Task is over and either returned a failed status, or had an exception
++
++* `max_timestamp`: int - Is optional. Returns only the scheduled tasks with a timestamp inferior to the specified one.
++
++**Response**
++
++The following fields are returned in the JSON response body along with a `200` HTTP status code:
++
++* `id`: string - ID of scheduled task.
++* `action`: string - The name of the scheduled task's action.
++* `status`: string - The status of the scheduled task.
++* `timestamp_ms`: integer - The timestamp (in milliseconds since the unix epoch) of the given task - If the status is "scheduled" then this represents when it should be launched.
++ Otherwise it represents the last time this task got a change of state.
++* `resource_id`: Optional string - The resource id of the scheduled task, if it possesses one
++* `result`: Optional Json - Any result of the scheduled task, if given
++* `error`: Optional string - If the task has the status "failed", the error associated with this failure
+diff --git a/synapse/rest/admin/__init__.py b/synapse/rest/admin/__init__.py
+index 5977ded4a0..cf809d1a27 100644
+--- a/synapse/rest/admin/__init__.py
++++ b/synapse/rest/admin/__init__.py
+@@ -86,6 +86,7 @@ from synapse.rest.admin.rooms import (
+ RoomStateRestServlet,
+ RoomTimestampToEventRestServlet,
+ )
++from synapse.rest.admin.scheduled_tasks import ScheduledTasksRestServlet
+ from synapse.rest.admin.server_notice_servlet import SendServerNoticeServlet
+ from synapse.rest.admin.statistics import (
+ LargestRoomsStatistics,
+@@ -338,6 +339,7 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
+ BackgroundUpdateStartJobRestServlet(hs).register(http_server)
+ ExperimentalFeaturesRestServlet(hs).register(http_server)
+ SuspendAccountRestServlet(hs).register(http_server)
++ ScheduledTasksRestServlet(hs).register(http_server)
+
+
+ def register_servlets_for_client_rest_resource(
+diff --git a/synapse/rest/admin/scheduled_tasks.py b/synapse/rest/admin/scheduled_tasks.py
+new file mode 100644
+index 0000000000..2ae13021b9
+--- /dev/null
++++ b/synapse/rest/admin/scheduled_tasks.py
+@@ -0,0 +1,70 @@
++#
++# This file is licensed under the Affero General Public License (AGPL) version 3.
++#
++# Copyright (C) 2025 New Vector, Ltd
++#
++# This program is free software: you can redistribute it and/or modify
++# it under the terms of the GNU Affero General Public License as
++# published by the Free Software Foundation, either version 3 of the
++# License, or (at your option) any later version.
++#
++# See the GNU Affero General Public License for more details:
++# <https://www.gnu.org/licenses/agpl-3.0.html>.
++#
++#
++#
++from typing import TYPE_CHECKING, Tuple
++
++from synapse.http.servlet import RestServlet, parse_integer, parse_string
++from synapse.http.site import SynapseRequest
++from synapse.rest.admin import admin_patterns, assert_requester_is_admin
++from synapse.types import JsonDict, TaskStatus
++
++if TYPE_CHECKING:
++ from synapse.server import HomeServer
++
++
++class ScheduledTasksRestServlet(RestServlet):
++ """Get a list of scheduled tasks and their statuses
++ optionally filtered by action name, resource id, status, and max timestamp
++ """
++
++ PATTERNS = admin_patterns("/scheduled_tasks$")
++
++ def __init__(self, hs: "HomeServer"):
++ self._auth = hs.get_auth()
++ self._store = hs.get_datastores().main
++
++ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
++ await assert_requester_is_admin(self._auth, request)
++
++ # extract query params
++ action_name = parse_string(request, "action_name")
++ resource_id = parse_string(request, "resource_id")
++ status = parse_string(request, "job_status")
++ max_timestamp = parse_integer(request, "max_timestamp")
++
++ actions = [action_name] if action_name else None
++ statuses = [TaskStatus(status)] if status else None
++
++ tasks = await self._store.get_scheduled_tasks(
++ actions=actions,
++ resource_id=resource_id,
++ statuses=statuses,
++ max_timestamp=max_timestamp,
++ )
++
++ json_tasks = []
++ for task in tasks:
++ result_task = {
++ "id": task.id,
++ "action": task.action,
++ "status": task.status,
++ "timestamp_ms": task.timestamp,
++ "resource_id": task.resource_id,
++ "result": task.result,
++ "error": task.error,
++ }
++ json_tasks.append(result_task)
++
++ return 200, {"scheduled_tasks": json_tasks}
+diff --git a/tests/rest/admin/test_scheduled_tasks.py b/tests/rest/admin/test_scheduled_tasks.py
+new file mode 100644
+index 0000000000..9654e9322b
+--- /dev/null
++++ b/tests/rest/admin/test_scheduled_tasks.py
+@@ -0,0 +1,192 @@
++#
++# This file is licensed under the Affero General Public License (AGPL) version 3.
++#
++# Copyright (C) 2025 New Vector, Ltd
++#
++# This program is free software: you can redistribute it and/or modify
++# it under the terms of the GNU Affero General Public License as
++# published by the Free Software Foundation, either version 3 of the
++# License, or (at your option) any later version.
++#
++# See the GNU Affero General Public License for more details:
++# <https://www.gnu.org/licenses/agpl-3.0.html>.
++#
++#
++#
++from typing import Mapping, Optional, Tuple
++
++from twisted.test.proto_helpers import MemoryReactor
++
++import synapse.rest.admin
++from synapse.api.errors import Codes
++from synapse.rest.client import login
++from synapse.server import HomeServer
++from synapse.types import JsonMapping, ScheduledTask, TaskStatus
++from synapse.util import Clock
++
++from tests import unittest
++
++
++class ScheduledTasksAdminApiTestCase(unittest.HomeserverTestCase):
++ servlets = [
++ synapse.rest.admin.register_servlets,
++ login.register_servlets,
++ ]
++
++ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
++ self.store = hs.get_datastores().main
++ self.admin_user = self.register_user("admin", "pass", admin=True)
++ self.admin_user_tok = self.login("admin", "pass")
++ self._task_scheduler = hs.get_task_scheduler()
++
++ # create and schedule a few tasks
++ async def _test_task(
++ task: ScheduledTask,
++ ) -> Tuple[TaskStatus, Optional[JsonMapping], Optional[str]]:
++ return TaskStatus.ACTIVE, None, None
++
++ async def _finished_test_task(
++ task: ScheduledTask,
++ ) -> Tuple[TaskStatus, Optional[JsonMapping], Optional[str]]:
++ return TaskStatus.COMPLETE, None, None
++
++ async def _failed_test_task(
++ task: ScheduledTask,
++ ) -> Tuple[TaskStatus, Optional[JsonMapping], Optional[str]]:
++ return TaskStatus.FAILED, None, "Everything failed"
++
++ self._task_scheduler.register_action(_test_task, "test_task")
++ self.get_success(
++ self._task_scheduler.schedule_task("test_task", resource_id="test")
++ )
++
++ self._task_scheduler.register_action(_finished_test_task, "finished_test_task")
++ self.get_success(
++ self._task_scheduler.schedule_task(
++ "finished_test_task", resource_id="finished_task"
++ )
++ )
++
++ self._task_scheduler.register_action(_failed_test_task, "failed_test_task")
++ self.get_success(
++ self._task_scheduler.schedule_task(
++ "failed_test_task", resource_id="failed_task"
++ )
++ )
++
++ def check_scheduled_tasks_response(self, scheduled_tasks: Mapping) -> list:
++ result = []
++ for task in scheduled_tasks:
++ if task["resource_id"] == "test":
++ self.assertEqual(task["status"], TaskStatus.ACTIVE)
++ self.assertEqual(task["action"], "test_task")
++ result.append(task)
++ if task["resource_id"] == "finished_task":
++ self.assertEqual(task["status"], TaskStatus.COMPLETE)
++ self.assertEqual(task["action"], "finished_test_task")
++ result.append(task)
++ if task["resource_id"] == "failed_task":
++ self.assertEqual(task["status"], TaskStatus.FAILED)
++ self.assertEqual(task["action"], "failed_test_task")
++ result.append(task)
++
++ return result
++
++ def test_requester_is_not_admin(self) -> None:
++ """
++ If the user is not a server admin, an error 403 is returned.
++ """
++
++ self.register_user("user", "pass", admin=False)
++ other_user_tok = self.login("user", "pass")
++
++ channel = self.make_request(
++ "GET",
++ "/_synapse/admin/v1/scheduled_tasks",
++ content={},
++ access_token=other_user_tok,
++ )
++
++ self.assertEqual(403, channel.code, msg=channel.json_body)
++ self.assertEqual(Codes.FORBIDDEN, channel.json_body["errcode"])
++
++ def test_scheduled_tasks(self) -> None:
++ """
++ Test that endpoint returns scheduled tasks.
++ """
++
++ channel = self.make_request(
++ "GET",
++ "/_synapse/admin/v1/scheduled_tasks",
++ content={},
++ access_token=self.admin_user_tok,
++ )
++ self.assertEqual(200, channel.code, msg=channel.json_body)
++ scheduled_tasks = channel.json_body["scheduled_tasks"]
++
++ # make sure we got back all the scheduled tasks
++ found_tasks = self.check_scheduled_tasks_response(scheduled_tasks)
++ self.assertEqual(len(found_tasks), 3)
++
++ def test_filtering_scheduled_tasks(self) -> None:
++ """
++ Test that filtering the scheduled tasks response via query params works as expected.
++ """
++ # filter via job_status
++ channel = self.make_request(
++ "GET",
++ "/_synapse/admin/v1/scheduled_tasks?job_status=active",
++ content={},
++ access_token=self.admin_user_tok,
++ )
++ self.assertEqual(200, channel.code, msg=channel.json_body)
++ scheduled_tasks = channel.json_body["scheduled_tasks"]
++ found_tasks = self.check_scheduled_tasks_response(scheduled_tasks)
++
++ # only the active task should have been returned
++ self.assertEqual(len(found_tasks), 1)
++ self.assertEqual(found_tasks[0]["status"], "active")
++
++ # filter via action_name
++ channel = self.make_request(
++ "GET",
++ "/_synapse/admin/v1/scheduled_tasks?action_name=test_task",
++ content={},
++ access_token=self.admin_user_tok,
++ )
++ self.assertEqual(200, channel.code, msg=channel.json_body)
++ scheduled_tasks = channel.json_body["scheduled_tasks"]
++
++ # only test_task should have been returned
++ found_tasks = self.check_scheduled_tasks_response(scheduled_tasks)
++ self.assertEqual(len(found_tasks), 1)
++ self.assertEqual(found_tasks[0]["action"], "test_task")
++
++ # filter via max_timestamp
++ channel = self.make_request(
++ "GET",
++ "/_synapse/admin/v1/scheduled_tasks?max_timestamp=0",
++ content={},
++ access_token=self.admin_user_tok,
++ )
++ self.assertEqual(200, channel.code, msg=channel.json_body)
++ scheduled_tasks = channel.json_body["scheduled_tasks"]
++ found_tasks = self.check_scheduled_tasks_response(scheduled_tasks)
++
++ # none should have been returned
++ self.assertEqual(len(found_tasks), 0)
++
++ # filter via resource id
++ channel = self.make_request(
++ "GET",
++ "/_synapse/admin/v1/scheduled_tasks?resource_id=failed_task",
++ content={},
++ access_token=self.admin_user_tok,
++ )
++ self.assertEqual(200, channel.code, msg=channel.json_body)
++ scheduled_tasks = channel.json_body["scheduled_tasks"]
++ found_tasks = self.check_scheduled_tasks_response(scheduled_tasks)
++
++ # only the task with the matching resource id should have been returned
++ self.assertEqual(len(found_tasks), 1)
++ self.assertEqual(found_tasks[0]["resource_id"], "failed_task")
+--
+2.49.0
+
|