Add /-/tasks introspection endpoint for supervised background tasks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Garcia 2026-07-30 18:36:58 -07:00 committed by GitHub
commit 19933186ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 194 additions and 1 deletions

View file

@ -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(\.(?P<format>json))?$",
)
add_route(
JsonDataView.as_view(
self, "tasks.json", self._tasks, permission="permissions-debug"
),
r"/-/tasks(\.(?P<format>json))?$",
)
add_route(
JsonDataView.as_view(
self,

View file

@ -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():

View file

@ -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 <json_api>`.
The introspection endpoints documented on this page are covered by the :ref:`JSON API stability promise <json_api_stability>`, 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 <json_api_stability>`, 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

View file

@ -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 <JsonDataView_threads>`,
:ref:`/-/tasks <JsonDataView_tasks>`,
:ref:`/-/actions <JsonDataView_actions>`,
the :ref:`permission debug endpoints <PermissionsDebugView>`
(``/-/allowed``, ``/-/rules``, ``/-/check``) and the

View file

@ -520,6 +520,7 @@ def view_instance_client():
"/-/plugins",
"/-/settings",
"/-/threads",
"/-/tasks",
"/-/databases",
"/-/permissions",
"/-/messages",

View file

@ -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):

View file

@ -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