diff --git a/datasette/app.py b/datasette/app.py index bcf71b53..a799ea1a 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -2285,6 +2285,21 @@ class Datasette: ) return d + def _tasks(self): + return { + "tasks": [ + { + "name": t.name, + "state": t.state, + "plugin": t.plugin, + "started_at": t.started_at, + "exception": repr(t.exception) if t.exception else None, + } + for t in self._background_tasks.tasks() + ], + "launched": self._background_tasks.launched, + } + def _actor(self, request): return {"actor": request.actor} @@ -2573,6 +2588,12 @@ class Datasette: ), r"/-/threads(\.(?Pjson))?$", ) + add_route( + JsonDataView.as_view( + self, "tasks.json", self._tasks, permission="permissions-debug" + ), + r"/-/tasks(\.(?Pjson))?$", + ) add_route( JsonDataView.as_view( self, diff --git a/datasette/background_tasks.py b/datasette/background_tasks.py index ee5e5015..0d3166f4 100644 --- a/datasette/background_tasks.py +++ b/datasette/background_tasks.py @@ -240,6 +240,15 @@ class BackgroundTaskSupervisor: """ return list(self._tasks) + @property + def launched(self) -> bool: + """Whether :meth:`launch_all` has run yet - lets ``/-/tasks`` + distinguish "no tasks registered" from "tasks registered but + nothing has armed the launch yet" without reaching for the + private ``_launched`` attribute. + """ + return self._launched + def _on_task_done(handle: BackgroundTask, task: asyncio.Task) -> None: if task.cancelled(): diff --git a/docs/introspection.rst b/docs/introspection.rst index 14b6249f..21d2de05 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -9,7 +9,7 @@ Each of these pages can be viewed in your browser. Add ``.json`` to the URL to g JSON responses that return an object include an ``"ok": true`` key, consistent with the rest of the :ref:`JSON API `. -The introspection endpoints documented on this page are covered by the :ref:`JSON API stability promise `, with the exception of the debug endpoints ``/-/threads`` and ``/-/actions``, whose shapes may change in future releases. +The introspection endpoints documented on this page are covered by the :ref:`JSON API stability promise `, with the exception of the debug endpoints ``/-/threads``, ``/-/tasks`` and ``/-/actions``, whose shapes may change in future releases. .. _JsonDataView_metadata: @@ -278,6 +278,54 @@ Shows details of threads and ``asyncio`` tasks. This endpoint requires the ``per ] } +.. _JsonDataView_tasks: + +/-/tasks +-------- + +Shows the state of every supervised background task registered with +``datasette.add_background_task()``. This endpoint requires +the ``permissions-debug`` permission, since a crashed task's ``exception`` +field can reveal internals such as file paths or query text: + +.. code-block:: json + + { + "ok": true, + "tasks": [ + { + "name": "my_plugin.poll_for_updates", + "state": "running", + "plugin": "my-plugin", + "started_at": "2026-07-30T12:00:00+00:00", + "exception": null + }, + { + "name": "my_plugin.broken_task", + "state": "crashed", + "plugin": "my-plugin", + "started_at": "2026-07-30T12:00:00+00:00", + "exception": "ValueError('something went wrong')" + } + ], + "launched": true + } + +Each entry's ``state`` is one of ``registered`` (added but not yet +launched), ``running``, ``completed``, ``crashed`` or ``cancelled``. +``exception`` is a one-line ``repr()`` of the exception for a ``crashed`` +task, or ``null`` otherwise - the full traceback is written to the +``datasette.background_tasks`` logger instead, to keep this payload +skimmable. + +The top-level ``launched`` flag reports whether the instance has run its +one-time background task launch (after ``startup`` hooks finish, or via +lifespan/first-request/``start_background_tasks()``). It distinguishes "no +tasks have been registered" (``tasks`` is empty either way) from "tasks are +registered but nothing has armed the launch yet" (``launched`` is +``false`` and every task's ``state`` is still ``registered``) - useful when +debugging a host that never triggers Datasette's lifespan events. + .. _JsonDataView_actor: /-/actor diff --git a/docs/json_api.rst b/docs/json_api.rst index a96fd73d..10f6078a 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -48,6 +48,7 @@ Some JSON endpoints are **exempt** from this promise: debug playground. - Debug and support endpoints are documented so you can use them, but their JSON shapes are not frozen: :ref:`/-/threads `, + :ref:`/-/tasks `, :ref:`/-/actions `, the :ref:`permission debug endpoints ` (``/-/allowed``, ``/-/rules``, ``/-/check``) and the diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 73c44682..0a77bd37 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -520,6 +520,7 @@ def view_instance_client(): "/-/plugins", "/-/settings", "/-/threads", + "/-/tasks", "/-/databases", "/-/permissions", "/-/messages", diff --git a/tests/test_success_envelope.py b/tests/test_success_envelope.py index d24a8c5d..9c95dc79 100644 --- a/tests/test_success_envelope.py +++ b/tests/test_success_envelope.py @@ -59,6 +59,7 @@ async def test_success_object_has_ok_true(ds_client, path): "/-/rules.json?action=view-instance", "/-/check.json?action=view-instance", "/-/threads.json", + "/-/tasks.json", ), ) async def test_permission_debug_success_has_ok_true(ds_envelope, path): diff --git a/tests/test_tasks_endpoint.py b/tests/test_tasks_endpoint.py new file mode 100644 index 00000000..acc4f08f --- /dev/null +++ b/tests/test_tasks_endpoint.py @@ -0,0 +1,112 @@ +""" +Tests for the /-/tasks introspection endpoint (todos/first-request/06-tasks-endpoint.md). + +/-/tasks exposes datasette._background_tasks (see tests/test_background_tasks.py +for the supervisor machinery itself) the same way /-/threads exposes threading +internals: gated behind the permissions-debug permission, JSON-only. +""" + +import asyncio +import contextlib + +import pytest + +from datasette.app import Datasette + + +@pytest.mark.asyncio +async def test_tasks_requires_permissions_debug(): + ds = Datasette(memory=True) + ds.root_enabled = True + + denied = await ds.client.get("/-/tasks.json") + assert denied.status_code == 403 + + allowed = await ds.client.get("/-/tasks.json", actor={"id": "root"}) + assert allowed.status_code == 200 + data = allowed.json() + assert data["ok"] is True + assert "tasks" in data + assert "launched" in data + + +@pytest.mark.asyncio +async def test_running_and_crashed_task_states(): + ds = Datasette(memory=True) + ds.root_enabled = True + + async def long_running(datasette): + await asyncio.Event().wait() + + async def crasher(datasette): + raise RuntimeError("kaboom") + + long_handle = ds.add_background_task(long_running, name="long-runner") + crash_handle = ds.add_background_task(crasher, name="crasher") + + await ds.start_background_tasks() + + # Let the crasher run to completion and its done-callback (which sets + # handle.state = "crashed") actually fire before we read state back out. + await asyncio.wait_for( + asyncio.gather(crash_handle.task, return_exceptions=True), timeout=5 + ) + await asyncio.sleep(0) + + try: + response = await ds.client.get("/-/tasks.json", actor={"id": "root"}) + assert response.status_code == 200 + data = response.json() + assert data["launched"] is True + + by_name = {t["name"]: t for t in data["tasks"]} + assert by_name["long-runner"]["state"] == "running" + assert by_name["long-runner"]["exception"] is None + assert by_name["long-runner"]["started_at"] is not None + + crashed = by_name["crasher"] + assert crashed["state"] == "crashed" + assert crashed["exception"] is not None + assert isinstance(crashed["exception"], str) + assert "kaboom" in crashed["exception"] + assert "RuntimeError" in crashed["exception"] + finally: + long_handle.cancel() + with contextlib.suppress(asyncio.CancelledError): + await long_handle.task + + +@pytest.mark.asyncio +async def test_cold_instance_launched_false_and_task_registered(): + ds = Datasette(memory=True) + ds.root_enabled = True + + async def task(datasette): + pass + + ds.add_background_task(task, name="cold-task") + + # ds.client / httpx.ASGITransport routes through the full ASGI app, + # including AsgiRunOnFirstRequest - the first-request fallback that + # itself launches background tasks (_launch_background_tasks) so hosts + # without lifespan support still get supervised tasks running. That + # means a plain HTTP request here would launch "cold-task" before we + # ever get a response, making the "never launched" state impossible to + # observe over HTTP. _suppress_background_tasks is the same switch the + # `datasette --get` CLI path sets to stop its one-shot request from + # launching long-lived work (see _launch_background_tasks's docstring + # in datasette/app.py) - setting it here keeps this one request from + # arming the launch, so we can still exercise the real permission-gated + # HTTP endpoint while asserting on a genuinely pre-launch snapshot. + ds._suppress_background_tasks = True + + response = await ds.client.get("/-/tasks.json", actor={"id": "root"}) + assert response.status_code == 200 + data = response.json() + assert data["launched"] is False + assert len(data["tasks"]) == 1 + task_data = data["tasks"][0] + assert task_data["name"] == "cold-task" + assert task_data["state"] == "registered" + assert task_data["started_at"] is None + assert task_data["exception"] is None