Add typed task input forms

This commit is contained in:
ajp_anton
2026-08-08 18:39:09 +00:00
parent 56d6194324
commit 0549ed1b0d
8 changed files with 521 additions and 16 deletions
+252 -5
View File
@@ -3,8 +3,15 @@
from __future__ import annotations from __future__ import annotations
import ast import ast
import json
import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any
FIELD_NAME = re.compile(r"[a-z][a-z0-9_]*\Z")
FIELD_TYPES = frozenset({"text", "integer", "date", "datetime", "choice", "multi_choice"})
def display_name(value: str) -> str: def display_name(value: str) -> str:
@@ -19,6 +26,27 @@ class Task:
path: Path path: Path
display_name: str display_name: str
description: str | None description: str | None
inputs: tuple["InputField", ...]
@dataclass(frozen=True)
class InputOption:
value: str
label: str
@dataclass(frozen=True)
class InputField:
name: str
label: str
type: str
required: bool
default: str | int | tuple[str, ...] | None
minimum: int | None
maximum: int | None
step: int | None
pattern: str | None
options: tuple[InputOption, ...]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -69,15 +97,22 @@ class TaskCatalog:
return None return None
def _tasks_in_group(self, group_path: Path) -> list[Task]: def _tasks_in_group(self, group_path: Path) -> list[Task]:
tasks = [] task_paths = [
for path in sorted(group_path.iterdir(), key=lambda candidate: candidate.name): path
if ( for path in sorted(group_path.iterdir(), key=lambda candidate: candidate.name)
if not (
path.name.startswith((".", "_")) path.name.startswith((".", "_"))
or path.is_symlink() or path.is_symlink()
or not path.is_file() or not path.is_file()
or path.suffix != ".py" or path.suffix != ".py"
): )
continue ]
inputs_by_filename = load_input_manifest(
group_path,
{path.name for path in task_paths},
)
tasks = []
for path in task_paths:
tasks.append( tasks.append(
Task( Task(
id=f"{group_path.name}/{path.name}", id=f"{group_path.name}/{path.name}",
@@ -86,6 +121,7 @@ class TaskCatalog:
path=path, path=path,
display_name=display_name(path.stem), display_name=display_name(path.stem),
description=read_description(path), description=read_description(path),
inputs=inputs_by_filename.get(path.name, ()),
) )
) )
return tasks return tasks
@@ -105,3 +141,214 @@ def read_description(path: Path) -> str | None:
(line.strip() for line in docstring.splitlines() if line.strip()), (line.strip() for line in docstring.splitlines() if line.strip()),
None, None,
) )
def load_input_manifest(
group_path: Path,
task_filenames: set[str],
) -> dict[str, tuple[InputField, ...]]:
"""Read the optional data-only input declaration for one task group."""
manifest_path = group_path / "task-inputs.json"
if not manifest_path.exists():
return {}
if manifest_path.is_symlink():
raise ValueError(f"Input manifest cannot be a symlink: {manifest_path}")
try:
data = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
raise ValueError(f"Could not read input manifest {manifest_path}: {error}") from error
if not isinstance(data, dict) or set(data) != {"version", "tasks"}:
raise ValueError(
f"Input manifest {manifest_path} must contain only version and tasks."
)
if data["version"] != 1 or not isinstance(data["tasks"], dict):
raise ValueError(f"Input manifest {manifest_path} has an unsupported structure.")
unknown_tasks = set(data["tasks"]) - task_filenames
if unknown_tasks:
names = ", ".join(sorted(unknown_tasks))
raise ValueError(f"Input manifest {manifest_path} names unknown tasks: {names}.")
return {
filename: parse_task_inputs(manifest_path, filename, declaration)
for filename, declaration in data["tasks"].items()
}
def parse_task_inputs(
manifest_path: Path,
filename: str,
declaration: Any,
) -> tuple[InputField, ...]:
if not isinstance(declaration, dict) or set(declaration) != {"inputs"}:
raise ValueError(f"{manifest_path} task {filename} must contain only inputs.")
raw_inputs = declaration["inputs"]
if not isinstance(raw_inputs, list):
raise ValueError(f"{manifest_path} task {filename} inputs must be a list.")
fields = tuple(parse_input_field(manifest_path, filename, raw) for raw in raw_inputs)
names = [field.name for field in fields]
if len(names) != len(set(names)):
raise ValueError(f"{manifest_path} task {filename} has duplicate input names.")
return fields
def parse_input_field(manifest_path: Path, filename: str, raw: Any) -> InputField:
if not isinstance(raw, dict):
raise ValueError(f"{manifest_path} task {filename} has a non-object input field.")
allowed = {
"name", "label", "type", "required", "default", "minimum", "maximum",
"step", "pattern", "options",
}
unknown = set(raw) - allowed
if unknown:
raise ValueError(
f"{manifest_path} task {filename} input has unknown keys: {', '.join(sorted(unknown))}."
)
name = raw.get("name")
label = raw.get("label")
field_type = raw.get("type")
if not isinstance(name, str) or not FIELD_NAME.fullmatch(name):
raise ValueError(f"{manifest_path} task {filename} has an invalid input name.")
if not isinstance(label, str) or not label.strip():
raise ValueError(f"{manifest_path} task {filename} input {name} needs a label.")
if field_type not in FIELD_TYPES:
raise ValueError(f"{manifest_path} task {filename} input {name} has an invalid type.")
required = raw.get("required", False)
if not isinstance(required, bool):
raise ValueError(f"{manifest_path} task {filename} input {name} required must be true or false.")
numeric_keys = ("minimum", "maximum", "step")
numeric_values = {key: raw.get(key) for key in numeric_keys}
if field_type == "integer":
for key, value in numeric_values.items():
if value is not None and (not isinstance(value, int) or isinstance(value, bool)):
raise ValueError(f"{manifest_path} task {filename} input {name} {key} must be an integer.")
if numeric_values["minimum"] is not None and numeric_values["maximum"] is not None and numeric_values["minimum"] > numeric_values["maximum"]:
raise ValueError(f"{manifest_path} task {filename} input {name} minimum exceeds maximum.")
if numeric_values["step"] is not None and numeric_values["step"] <= 0:
raise ValueError(f"{manifest_path} task {filename} input {name} step must be positive.")
elif any(value is not None for value in numeric_values.values()):
raise ValueError(f"{manifest_path} task {filename} input {name} only integer fields accept numeric limits.")
pattern = raw.get("pattern")
if pattern is not None:
if field_type != "text" or not isinstance(pattern, str):
raise ValueError(f"{manifest_path} task {filename} input {name} has an invalid pattern.")
try:
re.compile(pattern)
except re.error as error:
raise ValueError(f"{manifest_path} task {filename} input {name} has an invalid pattern: {error}.") from error
options = parse_options(manifest_path, filename, name, field_type, raw.get("options"))
default = parse_default(manifest_path, filename, name, field_type, options, raw.get("default"))
validate_default(
manifest_path,
filename,
name,
field_type,
default,
numeric_values["minimum"],
numeric_values["maximum"],
numeric_values["step"],
pattern,
)
return InputField(
name=name,
label=label,
type=field_type,
required=required,
default=default,
minimum=numeric_values["minimum"],
maximum=numeric_values["maximum"],
step=numeric_values["step"],
pattern=pattern,
options=options,
)
def parse_options(
manifest_path: Path,
filename: str,
name: str,
field_type: str,
raw_options: Any,
) -> tuple[InputOption, ...]:
if field_type not in {"choice", "multi_choice"}:
if raw_options is not None:
raise ValueError(f"{manifest_path} task {filename} input {name} only choice fields accept options.")
return ()
if not isinstance(raw_options, list) or not raw_options:
raise ValueError(f"{manifest_path} task {filename} input {name} needs non-empty options.")
options = []
for raw_option in raw_options:
if isinstance(raw_option, str) and raw_option:
options.append(InputOption(value=raw_option, label=raw_option))
elif (
isinstance(raw_option, dict)
and set(raw_option) == {"value", "label"}
and isinstance(raw_option["value"], str)
and raw_option["value"]
and isinstance(raw_option["label"], str)
and raw_option["label"].strip()
):
options.append(InputOption(value=raw_option["value"], label=raw_option["label"]))
else:
raise ValueError(f"{manifest_path} task {filename} input {name} has an invalid option.")
if len({option.value for option in options}) != len(options):
raise ValueError(f"{manifest_path} task {filename} input {name} has duplicate option values.")
return tuple(options)
def parse_default(
manifest_path: Path,
filename: str,
name: str,
field_type: str,
options: tuple[InputOption, ...],
default: Any,
) -> str | int | tuple[str, ...] | None:
if default is None:
return None
if field_type == "integer":
if not isinstance(default, int) or isinstance(default, bool):
raise ValueError(f"{manifest_path} task {filename} input {name} default must be an integer.")
return default
if field_type == "multi_choice":
if not isinstance(default, list) or not all(isinstance(value, str) for value in default):
raise ValueError(f"{manifest_path} task {filename} input {name} default must be a list of options.")
values = tuple(default)
if len(values) != len(set(values)) or not set(values) <= {option.value for option in options}:
raise ValueError(f"{manifest_path} task {filename} input {name} default contains an unknown option.")
return values
if not isinstance(default, str):
raise ValueError(f"{manifest_path} task {filename} input {name} default must be text.")
if field_type == "choice" and default not in {option.value for option in options}:
raise ValueError(f"{manifest_path} task {filename} input {name} default is not an option.")
return default
def validate_default(
manifest_path: Path,
filename: str,
name: str,
field_type: str,
default: str | int | tuple[str, ...] | None,
minimum: int | None,
maximum: int | None,
step: int | None,
pattern: str | None,
) -> None:
if default is None or field_type not in {"integer", "text"}:
return
if field_type == "text":
if pattern and not re.fullmatch(pattern, default):
raise ValueError(f"{manifest_path} task {filename} input {name} default does not match its pattern.")
return
assert isinstance(default, int)
if minimum is not None and default < minimum:
raise ValueError(f"{manifest_path} task {filename} input {name} default is below minimum.")
if maximum is not None and default > maximum:
raise ValueError(f"{manifest_path} task {filename} input {name} default is above maximum.")
if step is not None and (default - (minimum or 0)) % step:
raise ValueError(f"{manifest_path} task {filename} input {name} default does not match step.")
+87
View File
@@ -0,0 +1,87 @@
"""Validation for task-input manifest values."""
from __future__ import annotations
import re
from collections.abc import Mapping, Sequence
from datetime import date, datetime
from .discovery import InputField
def validate_inputs(
fields: Sequence[InputField],
values: Mapping[str, list[str]],
) -> tuple[dict[str, str | int | list[str]], dict[str, str]]:
result: dict[str, str | int | list[str]] = {}
errors: dict[str, str] = {}
for field in fields:
raw_values = values.get(field.name, [])
value, error = validate_field(field, raw_values)
if error:
errors[field.name] = error
elif value is not None:
result[field.name] = value
return result, errors
def validate_field(
field: InputField,
raw_values: list[str],
) -> tuple[str | int | list[str] | None, str | None]:
if field.type == "multi_choice":
return validate_multi_choice(field, raw_values)
if len(raw_values) > 1:
return None, "Only one value is allowed."
raw = raw_values[0] if raw_values else ""
if not raw:
if field.default is not None:
return field.default, None
if field.required:
return None, "This field is required."
return None, None
if field.type == "integer":
try:
value = int(raw)
except ValueError:
return None, "Enter a whole number."
if field.minimum is not None and value < field.minimum:
return None, f"Enter a value of at least {field.minimum}."
if field.maximum is not None and value > field.maximum:
return None, f"Enter a value no greater than {field.maximum}."
if field.step is not None and (value - (field.minimum or 0)) % field.step:
return None, f"Enter a value in increments of {field.step}."
return value, None
if field.type == "date":
try:
date.fromisoformat(raw)
except ValueError:
return None, "Enter a valid date."
elif field.type == "datetime":
try:
datetime.fromisoformat(raw)
except ValueError:
return None, "Enter a valid date and time."
elif field.type == "choice" and raw not in {option.value for option in field.options}:
return None, "Choose one of the available options."
elif field.type == "text" and field.pattern and not re.fullmatch(field.pattern, raw):
return None, "Enter a value in the required format."
return raw, None
def validate_multi_choice(
field: InputField,
raw_values: list[str],
) -> tuple[list[str] | None, str | None]:
if not raw_values:
if field.default is not None:
return list(field.default), None
if field.required:
return None, "Choose at least one option."
return None, None
allowed = {option.value for option in field.options}
if any(value not in allowed for value in raw_values):
return None, "Choose only from the available options."
if len(raw_values) != len(set(raw_values)):
return None, "Each option can be selected only once."
return raw_values, None
+18 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import json
import signal import signal
import subprocess import subprocess
import threading import threading
@@ -23,11 +24,16 @@ class RunManager:
self._processes: dict[int, subprocess.Popen[bytes]] = {} self._processes: dict[int, subprocess.Popen[bytes]] = {}
self._lock = threading.Lock() self._lock = threading.Lock()
def run_task(self, task: Task) -> tuple[Run, bool]: def run_task(
self,
task: Task,
input_data: dict[str, str | int | list[str]] | None = None,
) -> tuple[Run, bool]:
run, created = self.store.enqueue( run, created = self.store.enqueue(
target_kind="task", target_kind="task",
target_path=task.id, target_path=task.id,
queue_group=task.group_id, queue_group=task.group_id,
input_data=input_data,
) )
if created: if created:
self._wake_worker(task.group_id) self._wake_worker(task.group_id)
@@ -143,11 +149,22 @@ class RunManager:
task_state.mkdir(parents=True, exist_ok=True) task_state.mkdir(parents=True, exist_ok=True)
log_path = self.settings.state_root / "runs" / f"{run.id}.log" log_path = self.settings.state_root / "runs" / f"{run.id}.log"
log_path.parent.mkdir(parents=True, exist_ok=True) log_path.parent.mkdir(parents=True, exist_ok=True)
input_path = self.settings.state_root / "runs" / f"{run.id}.input.json"
try:
input_path.write_text(run.input_json + "\n", encoding="utf-8")
input_path.chmod(0o600)
except OSError as error:
return self.store.finish(
run.id,
status="failed",
message=f"Could not prepare task input: {error}",
)
environment = { environment = {
"PATH": "/usr/local/bin:/usr/bin:/bin", "PATH": "/usr/local/bin:/usr/bin:/bin",
"PYTHONUNBUFFERED": "1", "PYTHONUNBUFFERED": "1",
"SERVER_MAINTENANCE_CREDENTIALS": str(self.settings.credentials_root), "SERVER_MAINTENANCE_CREDENTIALS": str(self.settings.credentials_root),
"SERVER_MAINTENANCE_STATE": str(task_state), "SERVER_MAINTENANCE_STATE": str(task_state),
"SERVER_MAINTENANCE_INPUT": str(input_path),
} }
if self.settings.timezone_name: if self.settings.timezone_name:
environment["TZ"] = self.settings.timezone_name environment["TZ"] = self.settings.timezone_name
+20 -6
View File
@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
import json
from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
@@ -27,6 +29,7 @@ class Run:
parent_run_id: int | None parent_run_id: int | None
log_path: str | None log_path: str | None
message: str | None message: str | None
input_json: str
def now() -> str: def now() -> str:
@@ -56,10 +59,19 @@ class RunStore:
cancel_requested_at TEXT, cancel_requested_at TEXT,
parent_run_id INTEGER REFERENCES runs(id), parent_run_id INTEGER REFERENCES runs(id),
log_path TEXT, log_path TEXT,
message TEXT message TEXT,
input_json TEXT NOT NULL DEFAULT '{}'
) )
""" """
) )
columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(runs)").fetchall()
}
if "input_json" not in columns:
connection.execute(
"ALTER TABLE runs ADD COLUMN input_json TEXT NOT NULL DEFAULT '{}'"
)
connection.execute( connection.execute(
""" """
CREATE INDEX IF NOT EXISTS runs_queue_index CREATE INDEX IF NOT EXISTS runs_queue_index
@@ -93,7 +105,9 @@ class RunStore:
target_path: str, target_path: str,
queue_group: str, queue_group: str,
trigger: str = "manual", trigger: str = "manual",
input_data: Mapping[str, str | int | list[str]] | None = None,
) -> tuple[Run, bool]: ) -> tuple[Run, bool]:
input_json = json.dumps(input_data or {}, sort_keys=True, separators=(",", ":"))
with self._connect() as connection: with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE") connection.execute("BEGIN IMMEDIATE")
existing = connection.execute( existing = connection.execute(
@@ -112,10 +126,10 @@ class RunStore:
cursor = connection.execute( cursor = connection.execute(
""" """
INSERT INTO runs ( INSERT INTO runs (
target_kind, target_path, queue_group, trigger, status, created_at target_kind, target_path, queue_group, trigger, status, created_at, input_json
) VALUES (?, ?, ?, ?, 'queued', ?) ) VALUES (?, ?, ?, ?, 'queued', ?, ?)
""", """,
(target_kind, target_path, queue_group, trigger, now()), (target_kind, target_path, queue_group, trigger, now(), input_json),
) )
return self.get(cursor.lastrowid, connection=connection), True return self.get(cursor.lastrowid, connection=connection), True
@@ -146,8 +160,8 @@ class RunStore:
""" """
INSERT INTO runs ( INSERT INTO runs (
target_kind, target_path, queue_group, trigger, status, created_at, target_kind, target_path, queue_group, trigger, status, created_at,
started_at, parent_run_id started_at, parent_run_id, input_json
) VALUES ('task', ?, ?, ?, 'running', ?, ?, ?) ) VALUES ('task', ?, ?, ?, 'running', ?, ?, ?, '{}')
""", """,
( (
task_path, task_path,
+32 -4
View File
@@ -10,6 +10,7 @@ from flask import Flask, abort, redirect, render_template, request, url_for
from .config import Settings from .config import Settings
from .discovery import TaskCatalog from .discovery import TaskCatalog
from .inputs import validate_inputs
from .runner import RunManager from .runner import RunManager
from .store import RunStore from .store import RunStore
@@ -53,8 +54,12 @@ def create_app(settings: Settings | None = None) -> Flask:
return "" return ""
return datetime.fromisoformat(value).astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") return datetime.fromisoformat(value).astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
@app.get("/") def render_index(
def index(): *,
form_values: dict[str, dict[str, list[str]]] | None = None,
input_errors: dict[str, dict[str, str]] | None = None,
status: int = 200,
):
groups = catalog.groups() groups = catalog.groups()
latest = { latest = {
("group", group.id): store.last_run("group", group.id) for group in groups ("group", group.id): store.last_run("group", group.id) for group in groups
@@ -62,14 +67,37 @@ def create_app(settings: Settings | None = None) -> Flask:
for group in groups: for group in groups:
for task in group.tasks: for task in group.tasks:
latest[("task", task.id)] = store.last_run("task", task.id) latest[("task", task.id)] = store.last_run("task", task.id)
return render_template("index.html", groups=groups, latest=latest) return (
render_template(
"index.html",
groups=groups,
latest=latest,
form_values=form_values or {},
input_errors=input_errors or {},
),
status,
)
@app.get("/")
def index():
return render_index()
@app.post("/tasks/<path:task_id>/run") @app.post("/tasks/<path:task_id>/run")
def run_task(task_id: str): def run_task(task_id: str):
task = catalog.task(task_id) task = catalog.task(task_id)
if task is None: if task is None:
abort(404) abort(404)
run, _ = manager.run_task(task) submitted_values = {
field.name: request.form.getlist(field.name) for field in task.inputs
}
input_data, errors = validate_inputs(task.inputs, submitted_values)
if errors:
return render_index(
form_values={task.id: submitted_values},
input_errors={task.id: errors},
status=400,
)
run, _ = manager.run_task(task, input_data)
return redirect(url_for("run_detail", run_id=run.id)) return redirect(url_for("run_detail", run_id=run.id))
@app.post("/groups/<path:group_id>/run") @app.post("/groups/<path:group_id>/run")
+31
View File
@@ -55,6 +55,37 @@ a {
margin: .2rem 0; margin: .2rem 0;
} }
.tasks form {
min-width: 16rem;
}
.task-input {
margin: .5rem 0;
}
.task-input label,
.task-input fieldset label {
display: block;
}
.task-input input,
.task-input select {
box-sizing: border-box;
font: inherit;
max-width: 100%;
}
.task-input fieldset {
border: 0;
margin: 0;
padding: 0;
}
.input-error {
color: #b00020;
margin: .2rem 0;
}
.run-summary { .run-summary {
font-size: .9rem; font-size: .9rem;
opacity: .75; opacity: .75;
+43
View File
@@ -36,6 +36,49 @@
{% endif %} {% endif %}
</div> </div>
<form method="post" action="{{ url_for('run_task', task_id=task.id) }}"> <form method="post" action="{{ url_for('run_task', task_id=task.id) }}">
{% set values = form_values.get(task.id, {}) %}
{% set errors = input_errors.get(task.id, {}) %}
{% for field in task.inputs %}
<div class="task-input">
{% if field.type == "multi_choice" %}
<fieldset>
<legend>{{ field.label }}{% if field.required %} (required){% endif %}</legend>
{% set selected = values.get(field.name, field.default or ()) %}
{% for option in field.options %}
<label>
<input type="checkbox" name="{{ field.name }}" value="{{ option.value }}" {% if option.value in selected %}checked{% endif %}>
{{ option.label }}
</label>
{% endfor %}
</fieldset>
{% elif field.type == "choice" %}
<label>{{ field.label }}{% if field.required %} (required){% endif %}
<select name="{{ field.name }}" {% if field.required %}required{% endif %}>
{% if not field.required %}<option value="">No selection</option>{% endif %}
{% set selected = values.get(field.name, field.default or "") %}
{% for option in field.options %}
<option value="{{ option.value }}" {% if option.value == selected %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
{% else %}
{% set html_type = "number" if field.type == "integer" else "datetime-local" if field.type == "datetime" else field.type %}
<label>{{ field.label }}{% if field.required %} (required){% endif %}
<input
type="{{ html_type }}"
name="{{ field.name }}"
value="{{ values.get(field.name, field.default if field.default is not none else "") }}"
{% if field.required %}required{% endif %}
{% if field.minimum is not none %}min="{{ field.minimum }}"{% endif %}
{% if field.maximum is not none %}max="{{ field.maximum }}"{% endif %}
{% if field.step is not none %}step="{{ field.step }}"{% endif %}
{% if field.pattern %}pattern="{{ field.pattern }}"{% endif %}
>
</label>
{% endif %}
{% if errors.get(field.name) %}<p class="input-error">{{ errors[field.name] }}</p>{% endif %}
</div>
{% endfor %}
<button type="submit">Run</button> <button type="submit">Run</button>
</form> </form>
</li> </li>
+38
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import sqlite3
from app.store import RunStore from app.store import RunStore
@@ -19,3 +21,39 @@ def test_restart_marks_unfinished_runs_interrupted(settings) -> None:
assert interrupted is not None assert interrupted is not None
assert interrupted.status == "interrupted" assert interrupted.status == "interrupted"
assert interrupted.finished_at is not None assert interrupted.finished_at is not None
def test_initialize_migrates_existing_database_with_input_column(settings) -> None:
settings.state_root.mkdir()
with sqlite3.connect(settings.database_path) as connection:
connection.execute(
"""
CREATE TABLE runs (
id INTEGER PRIMARY KEY,
target_kind TEXT NOT NULL,
target_path TEXT NOT NULL,
queue_group TEXT NOT NULL,
trigger TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
exit_code INTEGER,
cancel_requested_at TEXT,
parent_run_id INTEGER,
log_path TEXT,
message TEXT
)
"""
)
store = RunStore(settings.database_path)
store.initialize()
run, _ = store.enqueue(
target_kind="task",
target_path="checks/status.py",
queue_group="checks",
input_data={"attempts": 2},
)
assert run.input_json == '{"attempts":2}'