stack: Provider-Schicht für Aufgaben-Tracker mit Super-Productivity-Adapter (#124)
CI / verify (push) Successful in 48s
Release / release (push) Successful in 35s

Files changed:
- .gitignore
- CHANGES.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/config.py
- tools/chemenu/errors.py
- tools/chemenu/tasks/__init__.py
- tools/chemenu/tasks/config.py
- tools/chemenu/tasks/protocol.py
- tools/chemenu/tasks/superproductivity.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_superproductivity.py
- tools/chemenu/tests/test_tasks_config.py
- tools/chemenu/tests/test_tasks_protocol.py
This commit is contained in:
torben committed 2026-09-19 21:46:56 +02:00
1 parent ee24b6e5b8
commit 1875449b31
16 files changed
+1193 -6

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
"""The task-tracker provider layer (Gitea #124, #119 D2/D3/D25).
`kb/` owns a project's durable memory; a task tracker owns its momentary open
loops (#119 D1). This package is the one place `wikitool` crosses that line -
never an instruction, never a second MCP server (#119 D25): a provider is a
Python object behind `chemenu.tasks.protocol.TaskReader`/`TaskWriter`, and
everything above this package (the future `wikitool review`/`new project`
commands, #125/#126) talks to that protocol and nothing provider-specific.
No command lives here yet - this package is a library, per #124's own scope
note ("Kein Kommando. Diese Schicht ist Bibliothek").
"""
+114
View File
@@ -0,0 +1,114 @@
"""Reads `.wikitool-tasks.json` (Gitea #124) - `config.TASKS_CONFIG_FILENAME`.
Same posture as `chemenu.upload.read_config`: absent means "no tracker
configured for this instance", a legitimate state that `doctor` reports as OK,
never as a fault. Malformed is a `ValidationError`, never silently ignored -
this file decides which provider (and which credentials) the review talks to,
so a broken one must not be read as "nothing configured".
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from chemenu import config
from chemenu.errors import ValidationError
# The providers this package ships an adapter for. Checked at read time so a
# typo in `provider` fails here, at the one place that knows the full list,
# rather than surfacing later as an unhelpful "unknown provider" from whatever
# code tried to dispatch on it.
KNOWN_PROVIDERS = ("superproductivity",)
@dataclass(frozen=True)
class Thresholds:
"""The weekly review's three staleness thresholds (#119 D10/D26) - each
named for the check it feeds, not for its unit alone, since two of the
three checks besides the "obvious" one also read a day count via
`datetime.timedelta`."""
stalled_waiting_days: int # check 2: a WAITING item older than this
unpaged_project_weeks: int # check 3: a tracker project with no kb/ page, older than this
someday_stale_months: int # check 5: a someday/maybe item untouched for this long
@dataclass(frozen=True)
class TasksConfig:
"""The opt-in, read from `.wikitool-tasks.json`."""
provider: str
thresholds: Thresholds
provider_config: dict[str, Any]
def read_config(root: "Any") -> "TasksConfig | None":
"""The tracker configuration for `root`, or `None` when the file is
absent - which means no tracker is configured for this instance, not that
one failed to load."""
from pathlib import Path
import json
path = Path(root) / config.TASKS_CONFIG_FILENAME
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValidationError(
f"{config.TASKS_CONFIG_FILENAME} is unreadable ({exc}). It decides which task "
"tracker the weekly review talks to, so a broken file is not treated as 'no "
"tracker configured' - fix it or delete it deliberately."
) from exc
if not isinstance(data, dict):
raise ValidationError(f"{config.TASKS_CONFIG_FILENAME} must contain a JSON object.")
expected = (
'{"schema": 1, "provider": "superproductivity", '
'"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, '
'"someday_stale_months": 5}, "superproductivity": {"backups_dir": "...", '
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."}}'
)
try:
provider = str(data["provider"])
raw_thresholds = data["thresholds"]
stalled_waiting_days = int(raw_thresholds["stalled_waiting_days"])
unpaged_project_weeks = int(raw_thresholds["unpaged_project_weeks"])
someday_stale_months = int(raw_thresholds["someday_stale_months"])
except (KeyError, TypeError, ValueError) as exc:
raise ValidationError(
f"{config.TASKS_CONFIG_FILENAME} is missing or misshapes a required field ({exc}). "
f"Expected: {expected}"
) from exc
if provider not in KNOWN_PROVIDERS:
raise ValidationError(
f"{config.TASKS_CONFIG_FILENAME}: unknown provider {provider!r}. "
f"Known: {', '.join(KNOWN_PROVIDERS)}"
)
for field_name, value in (
("stalled_waiting_days", stalled_waiting_days),
("unpaged_project_weeks", unpaged_project_weeks),
("someday_stale_months", someday_stale_months),
):
if value <= 0:
raise ValidationError(
f"{config.TASKS_CONFIG_FILENAME}: thresholds.{field_name} must be positive."
)
provider_config = data.get(provider)
if not isinstance(provider_config, dict):
raise ValidationError(
f"{config.TASKS_CONFIG_FILENAME}: missing or non-object {provider!r} section "
f"holding that provider's own connection settings. Expected: {expected}"
)
return TasksConfig(
provider=provider,
thresholds=Thresholds(
stalled_waiting_days=stalled_waiting_days,
unpaged_project_weeks=unpaged_project_weeks,
someday_stale_months=someday_stale_months,
),
provider_config=provider_config,
)
+133
View File
@@ -0,0 +1,133 @@
"""The provider-agnostic read/write shape (Gitea #124, #119 D26/D31).
Every provider adapter under `chemenu.tasks` implements `TaskReader` and, if
it can, `TaskWriter` - two separate `Protocol`s rather than one, because #124's
own acceptance criteria requires exactly that: "ein Adapter kann den
Schreibpfad nicht anbieten, ohne dass der Lesepfad davon beruehrt wird". A
provider whose write path cannot exist (see `SuperProductivityWriter`) simply
does not implement `TaskWriter` - nothing here forces it to.
The read shape is fixed by what the weekly review (#119 D26, built in #125)
needs and nothing more: which projects exist and when they were created, how
many open items each one has and which of those are `WAITING` with a
`follow_up_at` (#119 D9/D30 - the *only* two machine-readable parts of a
waiting-for item; the person stays in the title's free text), and which
someday/maybe items exist and when they last moved. None of this is cached
here - a `TaskReader` re-reads on every call, so a caller checking `verify()`
after a human's out-of-band step (see `chemenu.errors.HumanInterventionRequired`)
sees the current state, not a snapshot from before that step.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from typing import Optional, Protocol, Sequence
@dataclass(frozen=True)
class ProjectSummary:
"""One tracker project, as the review needs it: its name (the sole join
key with a `kb/gtd/` page, #119 D8) and when it was created."""
name: str
created: Optional[date]
@dataclass(frozen=True)
class WaitingItem:
"""One open item carrying the `WAITING` status (#119 D9/D30).
`title` is shown verbatim, person and all - the review never parses it.
`follow_up_at` is the one machine-readable date, and it is deliberately
**not** the item's due date (#119 D9: "ausdruecklich nicht das
Faelligkeitsdatum") - a provider that has no separate concept for this
must not fall back to reusing the due date, it must decide it cannot
supply the field and leave it `None` instead.
"""
title: str
follow_up_at: Optional[date]
@dataclass(frozen=True)
class OpenItems:
"""A project's momentary open-loop count, plus the subset that is
`WAITING`. `count` includes the waiting items - it is "how many open
items", not "how many open items that aren't waiting"."""
count: int
waiting: Sequence[WaitingItem]
@dataclass(frozen=True)
class SomedayItem:
"""One someday/maybe item: its title and when it last changed, for
check 5's staleness read (#119 D26)."""
title: str
modified: Optional[date]
def normalize_project_name(name: str) -> str:
"""The case- and whitespace-normalized form of a project name (#119 D8):
collapse internal whitespace, then casefold. Used both ways round - to
preflight a name against the read path before a create, and to check
whether a human's out-of-band creation (`HumanInterventionRequired.verify`)
actually landed - so the same normalization must decide both, or a name
that passes one check could fail the other."""
return " ".join(name.strip().split()).casefold()
class TaskReader(Protocol):
"""The read path every provider adapter must implement."""
def projects(self) -> list[ProjectSummary]:
"""Every project the tracker currently knows, in no particular
order."""
...
def open_items(self, project_name: str) -> OpenItems:
"""Open items for the project named `project_name` (matched
case-normalized, #119 D8). A project the tracker does not know
returns `OpenItems(count=0, waiting=())` - "no open items" and "no
such project" are not distinguished here, because check 3 (#119 D26)
is what tells those apart, over the read path's `projects()` list."""
...
def someday_items(self) -> list[SomedayItem]:
"""Every someday/maybe item the tracker currently holds, across all
projects."""
...
class TaskWriter(Protocol):
"""The write path a provider adapter offers only if it actually can
(#124's own acceptance criteria: offering this must not touch the read
path's availability)."""
def create_project(self, name: str) -> None:
"""Create a tracker project named `name`, after checking `name` is
not already taken (case-normalized, #119 D8) via the read path.
Raises `chemenu.errors.ValidationError` if the name collides, or if
the provider is reachable but refuses for a reason a human cannot fix
by way of `chemenu.errors.HumanInterventionRequired` (e.g. the
provider app is simply not running). Raises
`chemenu.errors.HumanInterventionRequired` if this provider has no way
to create a project itself and a human must do it out of band - see
that class's docstring for the full contract, including `verify()`.
"""
...
def find_project(reader: TaskReader, name: str) -> Optional[ProjectSummary]:
"""The project matching `name` case-normalized (#119 D8), or `None`.
Shared by a `TaskWriter`'s preflight collision check and by a
`HumanInterventionRequired.verify()` closure - both are the same
question, "does a project by this name exist right now", asked at two
different moments."""
target = normalize_project_name(name)
for project in reader.projects():
if normalize_project_name(project.name) == target:
return project
return None
+318
View File
@@ -0,0 +1,318 @@
"""The Super Productivity adapter (Gitea #124, #119 D2/D8/D9/D30).
Read and write deliberately use different transports (#124's own design note,
carried as its first acceptance criterion): the read path is headless file
access, the write path is Super Productivity's own local REST API - so a
review can run with the app closed, and a create only needs the app open when
it actually has to ask it for something.
## Read path: the backup snapshot, not a live `db.json`
Desktop Super Productivity keeps its live state in IndexedDB, not in a flat
file called `db.json` on disk - there is no such file to read headlessly.
What *does* exist as a plain file is a periodic snapshot: `electron/backup.ts`
writes the complete app state as `JSON.stringify(data)` into
`<userData>/backups/<timestamp>.json` on every backup, newest-timestamp-last
by filename (its own comment: "timestamps sort lexically"). That snapshot's
top-level shape is `AppDataComplete`/`AppDataCompleteLegacy`
(`src/app/op-log/model/model-config.ts` / `src/app/imex/sync/sync.model.ts`),
keyed by feature name - `task`, `project`, `tag`, ... - and this reader only
looks at the three keys it needs, each an `@ngrx/entity` `EntityState`
(`{"ids": [...], "entities": {...}}`, `packages/plugin-api/src/types.ts`
`Task`/`Project`/`Tag`). Verified against the `master` branch of
`super-productivity/super-productivity` on 2026-09-19 - unversioned, per
#124's own note, so a future release is free to reshape it without warning,
which is exactly why every read below fails loudly on a shape it does not
recognize rather than guessing.
## The two GTD conventions this adapter encodes (#119 D9/D30)
Only the `WAITING` status and `follow_up_at` are machine-readable, and Super
Productivity has no native field for either:
- **`WAITING`** is a tag named `waiting` (case-insensitively), attached to the
task. Any other tag is left alone.
- **`follow_up_at`** is the task's own `remindAt` (a reminder timestamp) -
deliberately not `dueDay`/`dueWithTime` (#119 D9: "ausdruecklich nicht das
Faelligkeitsdatum"). A task with no reminder set has no `follow_up_at`, full
stop; this adapter never substitutes the due date for it.
## Someday/Maybe: a project's own backlog
#124 (later, #119) left this as "zu verifizieren": Super Productivity's
`ProjectBasicCfg.backlogTaskIds` is exactly this - a second, separate list of
task ids per project, apart from the active `taskIds` list a project's board
shows. This adapter reads someday/maybe items from `backlogTaskIds`, one
project at a time; no tag convention is needed.
## Write path: no project-creation endpoint exists
The local REST API (`electron/local-rest-api-handler.service.ts`, verified the
same day) routes `GET /projects` but has no `POST /projects` at all - task
CRUD exists, project CRUD does not. `SuperProductivityWriter.create_project`
can therefore not create a project itself; see its docstring and
`chemenu.errors.HumanInterventionRequired`.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Optional
from chemenu.errors import HumanInterventionRequired, ValidationError
from chemenu.tasks.protocol import (
OpenItems,
ProjectSummary,
SomedayItem,
WaitingItem,
find_project,
normalize_project_name,
)
# The tag title that means "WAITING" (#119 D9/D30), matched case-insensitively
# - this instance's own convention, not something Super Productivity defines.
WAITING_TAG_TITLE = "waiting"
DEFAULT_API_BASE_URL = "http://127.0.0.1:3876"
_EXPECTED_PROVIDER_CONFIG = (
'{"backups_dir": "~/.config/superProductivity/backups", '
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."}'
' (or "db_path" instead of "backups_dir" to pin one exact file)'
)
@dataclass(frozen=True)
class SuperProductivityConfig:
"""This provider's own section of `.wikitool-tasks.json`
(`TasksConfig.provider_config`)."""
# Exactly one of these two names where to read from. `db_path` wins when
# both are set - it names one exact file, which is a stronger statement
# than "the newest file in this directory".
backups_dir: Optional[Path]
db_path: Optional[Path]
api_base_url: str
# Unused by this module today - nothing it calls needs authentication
# (`GET /health` is the one unauthenticated exception, and there is no
# write call at all, see the module docstring). Carried through anyway so
# a future capability that does need it does not require a config-shape
# migration to add it.
api_token: Optional[str]
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SuperProductivityConfig":
if not isinstance(data, dict):
raise ValidationError(
f"superproductivity config must be an object. Expected: {_EXPECTED_PROVIDER_CONFIG}"
)
backups_dir = data.get("backups_dir")
db_path = data.get("db_path")
if backups_dir is None and db_path is None:
raise ValidationError(
"superproductivity config needs 'backups_dir' or 'db_path' - the read path has "
f"nothing to read otherwise. Expected: {_EXPECTED_PROVIDER_CONFIG}"
)
api_base_url = str(data.get("api_base_url") or DEFAULT_API_BASE_URL)
api_token = data.get("api_token")
if api_token is not None and not isinstance(api_token, str):
raise ValidationError("superproductivity config: api_token must be a string.")
return cls(
backups_dir=Path(backups_dir).expanduser() if backups_dir else None,
db_path=Path(db_path).expanduser() if db_path else None,
api_base_url=api_base_url,
api_token=api_token,
)
def latest_snapshot_path(cfg: SuperProductivityConfig) -> Path:
"""The one file to read: `db_path` if given, else the lexically-greatest
`*.json` filename under `backups_dir` - the same ordering
`electron/backup.ts` itself relies on ("timestamps sort lexically")."""
if cfg.db_path is not None:
if not cfg.db_path.is_file():
raise ValidationError(
f"superproductivity: db_path does not exist: {cfg.db_path}"
)
return cfg.db_path
directory = cfg.backups_dir
assert directory is not None # from_dict guarantees at least one is set
if not directory.is_dir():
raise ValidationError(
f"superproductivity: backups_dir does not exist: {directory}"
)
candidates = sorted(directory.glob("*.json"))
if not candidates:
raise ValidationError(
f"superproductivity: no *.json backup file found under {directory}. Take a "
"backup from Super Productivity (Settings -> Backup & Sync -> Local backups), "
"or point db_path/backups_dir at where it actually writes them."
)
return candidates[-1]
def _load_snapshot(cfg: SuperProductivityConfig) -> dict[str, Any]:
path = latest_snapshot_path(cfg)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValidationError(f"superproductivity: cannot read {path} ({exc}).") from exc
if not isinstance(data, dict):
raise ValidationError(
f"superproductivity: {path} does not contain a JSON object at its top level."
)
return data
def _entity_state(snapshot: dict[str, Any], key: str, source: Path) -> dict[str, dict]:
"""`snapshot[key]` as an `@ngrx/entity` `{"ids": [...], "entities": {...}}`
map, or a loud `ValidationError` naming exactly what was expected - Super
Productivity's internal model is unversioned (module docstring), so a
shape drift here is expected eventually, not a bug in this adapter."""
value = snapshot.get(key)
if (
not isinstance(value, dict)
or not isinstance(value.get("ids"), list)
or not isinstance(value.get("entities"), dict)
):
raise ValidationError(
f"superproductivity: {source} has no usable '{key}' entity state "
f"({{\"ids\": [...], \"entities\": {{...}}}}). Its internal shape is unversioned and "
"this file may be from a Super Productivity version this adapter does not know - "
f"found: {type(value).__name__ if value is not None else 'missing'}."
)
return value["entities"]
def _epoch_ms_to_date(value: Any) -> Optional[date]:
if not isinstance(value, (int, float)):
return None
return datetime.fromtimestamp(value / 1000, tz=timezone.utc).date()
class SuperProductivityReader:
"""`TaskReader` over a Super Productivity backup snapshot. Re-reads the
snapshot on every call - see `chemenu.errors.HumanInterventionRequired`
for why that matters."""
def __init__(self, cfg: SuperProductivityConfig):
self._cfg = cfg
def _read(self) -> tuple[Path, dict[str, dict], dict[str, dict], dict[str, dict]]:
path = latest_snapshot_path(self._cfg)
snapshot = _load_snapshot(self._cfg)
projects = _entity_state(snapshot, "project", path)
tasks = _entity_state(snapshot, "task", path)
tags = _entity_state(snapshot, "tag", path)
return path, projects, tasks, tags
def projects(self) -> list[ProjectSummary]:
_, projects, _, _ = self._read()
return [
ProjectSummary(
name=str(record.get("title", "")),
created=_epoch_ms_to_date(record.get("created")),
)
for record in projects.values()
]
def _is_waiting(self, task: dict, tags: dict[str, dict]) -> bool:
for tag_id in task.get("tagIds") or []:
tag = tags.get(tag_id)
if tag and str(tag.get("title", "")).strip().casefold() == WAITING_TAG_TITLE:
return True
return False
def open_items(self, project_name: str) -> OpenItems:
_, projects, tasks, tags = self._read()
target = normalize_project_name(project_name)
project = next(
(p for p in projects.values() if normalize_project_name(str(p.get("title", ""))) == target),
None,
)
if project is None:
return OpenItems(count=0, waiting=())
waiting: list[WaitingItem] = []
count = 0
for task_id in project.get("taskIds") or []:
task = tasks.get(task_id)
if task is None or task.get("isDone"):
continue
count += 1
if self._is_waiting(task, tags):
waiting.append(
WaitingItem(
title=str(task.get("title", "")),
follow_up_at=_epoch_ms_to_date(task.get("remindAt")),
)
)
return OpenItems(count=count, waiting=tuple(waiting))
def someday_items(self) -> list[SomedayItem]:
_, projects, tasks, _ = self._read()
items: list[SomedayItem] = []
for project in projects.values():
for task_id in project.get("backlogTaskIds") or []:
task = tasks.get(task_id)
if task is None or task.get("isDone"):
continue
items.append(
SomedayItem(
title=str(task.get("title", "")),
modified=_epoch_ms_to_date(task.get("updated") or task.get("created")),
)
)
return items
def health(cfg: SuperProductivityConfig, *, timeout: float = 2.0) -> bool:
"""Whether the local REST API answers `GET /health` right now - the one
unauthenticated endpoint (module docstring). Never raises: an unreachable
app is an ordinary, expected state (`doctor` reports it, it does not
FAIL), not a defect in this adapter."""
url = cfg.api_base_url.rstrip("/") + "/health"
try:
with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310 - localhost only
return 200 <= response.status < 300
except (urllib.error.URLError, OSError, ValueError):
return False
class SuperProductivityWriter:
"""`TaskWriter` over the local REST API - except there is no API call
this can actually make, see the module docstring."""
def __init__(self, cfg: SuperProductivityConfig, reader: SuperProductivityReader):
self._cfg = cfg
self._reader = reader
def create_project(self, name: str) -> None:
"""Never creates anything. Preflights the name against the read path
(#119 D8) and, if it is free, raises `HumanInterventionRequired`
naming the one thing a human must do - Super Productivity's local
REST API has no project-creation endpoint at all (module docstring),
so this is not a missing feature in this adapter, it is a missing
endpoint upstream."""
existing = find_project(self._reader, name)
if existing is not None:
raise ValidationError(
f"A project named '{name}' (case-insensitively) already exists in "
"Super Productivity - nothing was created."
)
def _verify() -> bool:
return find_project(self._reader, name) is not None
raise HumanInterventionRequired(
"Super Productivity's local REST API has no project-creation endpoint "
"(only GET /projects) - this cannot be automated.\n"
f" 1. Open Super Productivity.\n"
f" 2. Create a project named exactly: {name}\n"
" 3. Tell the agent you have done this, so it can re-check and continue.",
verify=_verify,
)