SP-Zugriffsweg explizit (access: api/snapshot, #133) und follow_up_at-Korrektur (dueWithTime/dueDay, #135)
CI / verify (push) Successful in 50s
Release / release (push) Successful in 36s

Files changed:
- CHANGES.md
- INSTALL.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/review.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_new_page.py
- tools/chemenu/tests/test_review.py
- tools/chemenu/tests/test_superproductivity.py
- tools/chemenu/tests/test_tasks_config.py
This commit is contained in:
torben committed 2026-09-20 20:52:05 +02:00
1 parent 3c1d4cb028
commit e07d1ca42a
17 files changed
+1089 -216

No files matched your search

+35 -3
View File
@@ -59,15 +59,20 @@ concern - readable here, never shipped as something to parse.
--- ---
## 7.0.0-beta.10 - 2026-09-20 - Veraltete Skill-Aufzaehlungen in der Instruction-Schicht nachgezogen ## 7.0.0-beta.11 - 2026-09-20 - SP-Zugriffsweg explizit (access: api/snapshot, #133) und follow_up_at-Korrektur (dueWithTime/dueDay, #135)
**Author:** Torben Nehmer **Author:** Torben Nehmer
**Breaking Change:** docs verify now requires an adopted `project` type-spec (schema requiring `state:`) and its `kb/gtd/` collection - an instance must adopt types/project.md(.schema.yaml) and kb/gtd/COLLECTION.md from their .template before docs verify passes again **Breaking Change:**
- docs verify now requires an adopted `project` type-spec (schema requiring `state:`) and its `kb/gtd/` collection - an instance must adopt types/project.md(.schema.yaml) and kb/gtd/COLLECTION.md from their .template before docs verify passes again
- superproductivity's provider section in .wikitool-tasks.json now requires 'access' ('api' or 'snapshot'), no default and no fallback between the two; 'db_path' no longer exists at all. An existing config must add 'access' and, if it used 'db_path', switch to 'backups_dir' (see INSTALL.md's example).
**Migration:** none required - no kb/ page content changes - the fix is the ordinary .template adoption every root:kb type already requires, not a data migration **Migration:** none required - No kb/ page content changes - the break is confined to .wikitool-tasks.json, an instance-owned, gitignored file every operator already edits by hand per INSTALL.md's example.
<!-- wikitool:bumps --> <!-- wikitool:bumps -->
**High impact**
- SP-Zugriffsweg explizit (access: api/snapshot, #133) und follow_up_at-Korrektur (dueWithTime/dueDay, #135)
**Medium impact** **Medium impact**
- Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart - Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart
- Task-tracker provider layer, with a Super Productivity adapter - Task-tracker provider layer, with a Super Productivity adapter
@@ -333,6 +338,33 @@ sechs der acht Skills und trug die veraltete Zahl im Namen; er nennt jetzt alle
Familien erklaert. Dass die Passage weiterhin Schrittzahlen fremder Dateien zitiert, die genauso Familien erklaert. Dass die Passage weiterhin Schrittzahlen fremder Dateien zitiert, die genauso
still veralten koennen, ist als eigene Frage festgehalten und hier bewusst nicht geloest. still veralten koennen, ist als eigene Frage festgehalten und hier bewusst nicht geloest.
### SP-Zugriffsweg explizit (access: api/snapshot, #133) und follow_up_at-Korrektur (dueWithTime/dueDay, #135)
Gitea #133: der Super-Productivity-Adapter unterscheidet jetzt zwei sich ausschliessende
Zugriffswege, per Pflichtfeld `access` ohne Default und ohne Laufzeit-Ruckfall - `"api"` liest
und (fuer #132 vorbereitet) schreibt ausschliesslich ueber die lokale REST-API und den aktuellen
Zustand, `"snapshot"` liest ausschliesslich den juengsten Backup-Schnappschuss und ist von dort
aus nie schreibbar. `db_path` entfaellt vollstaendig, `backups_dir`s Glob ist auf das echte
Zeitstempelmuster (`YYYY-MM-DD_HHmmss.json`) gehaertet, und beide Wege blenden archivierte
Tracker-Projekte aus - verifiziert gegen den tatsaechlichen Quellcode von
`super-productivity/super-productivity` (`master`, 2026-09-20). `SuperProductivityApiReader` ist
der neue Leser fuer `access: "api"`, gegen eine Attrappe getestet, nie gegen eine laufende App.
`tools/wikitool new project` verweigert auf einer `access: "snapshot"`-Instanz jetzt vollstaendig
(Exit 1, weder Tracker-Projekt noch Seite) statt den nicht mehr moeglichen 42er-Menschenschritt zu
versuchen; `wikitool doctor` und `wikitool review` berichten nur noch den tatsaechlich
konfigurierten Weg, und jede Antwort von `review` nennt jetzt explizit, aus welchem Weg sie
stammt (bei `snapshot` samt Alter des gelesenen Schnappschusses). Aufgeloest damit: #134, dessen
Verdacht (`--resume` koennte gegen einen veralteten Schnappschuss verifizieren) durch den Wegfall
der Projektanlage auf `snapshot`-Instanzen gegenstandslos wurde.
Gitea #135, im selben Zug korrigiert: `follow_up_at` las bislang `remindAt`, das sich nur bei
einer mit Uhrzeit terminierten und benachrichtigten Aufgabe fuellt - ein ganztaegiger, stiller
Tickler (`dueDay` ohne `dueWithTime`, das haeufigste WAITING-Muster) hatte dadurch nie einen
`follow_up_at` und fiel bei Pruefung 2 des Wochenrueckblicks still durch. Gelesen wird jetzt
`dueWithTime`, sonst `dueDay` - Super Productivitys eigene Leseregel - nie `deadline*` (D9 bleibt
in der Sache unveraendert, nur die falsche Berufung auf sie ist korrigiert). Bestehende Instanzen
sehen dadurch rueckblickend mehr Befunde, nicht weniger.
--- ---
## 6.2.0 - 2026-09-19 - Entity-Subtyp project nach codebase umbenannt ## 6.2.0 - 2026-09-19 - Entity-Subtyp project nach codebase umbenannt
+26 -5
View File
@@ -309,20 +309,41 @@ unlesbare Konfiguration darf nicht als „kein Tracker" durchgehen.
"someday_stale_months": 5 "someday_stale_months": 5
}, },
"superproductivity": { "superproductivity": {
"backups_dir": "~/.config/superProductivity/backups", "access": "api",
"api_base_url": "http://127.0.0.1:3876", "api_base_url": "http://127.0.0.1:3876",
"api_token": "<token aus den SP-Einstellungen>" "api_token": "<token aus den SP-Einstellungen>"
} }
} }
``` ```
```json
"superproductivity": {
"access": "snapshot",
"backups_dir": "~/.config/superProductivity/backups"
}
```
`provider` wählt den Adapter - ausgeliefert wird bislang `superproductivity`. Der `thresholds`- `provider` wählt den Adapter - ausgeliefert wird bislang `superproductivity`. Der `thresholds`-
Block trägt die drei Schwellwerte des Rückblicks (Konfiguration, nicht Schema): ab wann ein Block trägt die drei Schwellwerte des Rückblicks (Konfiguration, nicht Schema): ab wann ein
Waiting-For überfällig ist, ab welchem Alter ein Tracker-Projekt ohne `kb/`-Seite gemeldet wird, Waiting-For überfällig ist, ab welchem Alter ein Tracker-Projekt ohne `kb/`-Seite gemeldet wird,
und ab wann ein Someday-Eintrag als verstaubt gilt. Der gleichnamige Provider-Block trägt dessen und ab wann ein Someday-Eintrag als verstaubt gilt.
Verbindungsangaben; bei Super Productivity sind Lese- und Schreibpfad verschieden - gelesen wird
der jüngste Backup-Schnappschuss unter `backups_dir` (läuft auch ohne laufende App), geschrieben Der gleichnamige Provider-Block trägt dessen Verbindungsangaben, und bei Super Productivity
über die lokale REST-API, die nur antwortet, solange die App läuft. entscheidet `access` **verpflichtend und ohne Rückfall**, welcher von zwei sich ausschließenden
Wegen das ist: eine headless bediente Instanz setzt `access: "snapshot"` und liest
ausschließlich den jüngsten Backup-Schnappschuss unter `backups_dir` (läuft auch ohne laufende
App, aber rein lesend - der Tracker ist von dort aus nicht schreibbar); eine Desktop-Instanz
setzt `access: "api"` und spricht ausschließlich die lokale REST-API an, die nur antwortet,
solange die App läuft, dafür aber auch den aktuellen Zustand liefert und den Schreibpfad trägt.
Der Block nennt nur die Felder seines eigenen Wegs - ein `backups_dir` neben `access: "api"` oder
ein `api_token` neben `access: "snapshot"` wird beim Lesen der Konfiguration abgelehnt, nicht
ignoriert. `api_token` ist bei `access: "api"` Pflicht, da jeder Endpunkt außer `GET /health`
`Authorization: Bearer <token>` verlangt.
`tools/wikitool new project` legt einen gleichnamigen Tracker-Eintrag nur auf einer
`access: "api"`-Instanz an (und auch dort nicht automatisch - siehe die Kommandotabelle). Auf
einer `access: "snapshot"`-Instanz verweigert das Kommando vollständig, exit 1: der Tracker ist
von dort aus nur lesbar.
## Verifikation ## Verifikation
+1 -1
View File
@@ -1 +1 @@
7.0.0-beta.10 7.0.0-beta.11
+4 -4
View File
@@ -94,7 +94,7 @@ tools/wikitool <command> --help
| `new concept --name "<Name>" --set concept_type=<t> ...` | Scaffold `kb/concepts/<Name>.md` | | `new concept --name "<Name>" --set concept_type=<t> ...` | Scaffold `kb/concepts/<Name>.md` |
| `new source --name "<Name>" --set raw_files=raw/notes/x.md,raw/notes/y.md [--set source_url=<URL>] [--set entities=A,B] [--set concepts=C,D]` | Scaffold `kb/sources/Source - <Name>.md` (prefix added automatically) with a `raw_files:` list (rejects paths that don't exist) | | `new source --name "<Name>" --set raw_files=raw/notes/x.md,raw/notes/y.md [--set source_url=<URL>] [--set entities=A,B] [--set concepts=C,D]` | Scaffold `kb/sources/Source - <Name>.md` (prefix added automatically) with a `raw_files:` list (rejects paths that don't exist) |
| `new comparison --name "X vs Y" --set entities=X,Y` | Scaffold `kb/comparisons/X vs Y.md` | | `new comparison --name "X vs Y" --set entities=X,Y` | Scaffold `kb/comparisons/X vs Y.md` |
| `new project --name "<Name>" --set responsibility=<bereich> [--resume]` | Scaffold `kb/gtd/<bereich>/<Name>.md` **and**, if `.wikitool-tasks.json` configures a task tracker, a same-named tracker project - one name, one identity. Tracker before page: the tracker side is settled first, so a failure past that point leaves a tracker project with no page - a state `review`'s check 3 already reports - never a page with no tracker project. No tracker configured is a legitimate, explicitly announced state (page only). A name already taken (case-insensitively) in `kb/` or the tracker is refused outright, naming where it was found, and creates nothing. A provider with no write path of its own (Super Productivity has no project-creation endpoint) raises `chemenu.errors.HumanInterventionRequired`; the command shows its instructions and exits **42** (`needs_clearance()`, same posture as the four named gates, without being a fifth one - see that class's docstring), creating nothing. `--resume` is how a later run tells the command a human has done what that message asked: it re-verifies via the read path (`find_project`) before continuing to page creation, rather than trusting the claim, and repeats the same 42 if the tracker still doesn't have it. `--resume` on any other type is refused | | `new project --name "<Name>" --set responsibility=<bereich> [--resume]` | Scaffold `kb/gtd/<bereich>/<Name>.md` **and**, if `.wikitool-tasks.json` configures a task tracker, a same-named tracker project - one name, one identity. Tracker before page: the tracker side is settled first, so a failure past that point leaves a tracker project with no page - a state `review`'s check 3 already reports - never a page with no tracker project. No tracker configured is a legitimate, explicitly announced state (page only). A name already taken (case-insensitively) in `kb/` or the tracker is refused outright, naming where it was found, and creates nothing. A provider whose *configured access path* has no write path (Super Productivity's `access: "snapshot"` - the tracker is read-only from there by construction) refuses **entirely**, exit **1**, naming the `access: "api"` instance to use instead - neither the tracker project nor the page is created, and `--resume` behaves the same. A provider that could write but has no project-creation endpoint of its own (Super Productivity's `access: "api"` - `GET /projects` exists, `POST /projects` does not) raises `chemenu.errors.HumanInterventionRequired`; the command shows its instructions and exits **42** (`needs_clearance()`, same posture as the four named gates, without being a fifth one - see that class's docstring), creating nothing. `--resume` is how a later run tells the command a human has done what that message asked: it re-verifies via the read path (`find_project`) before continuing to page creation, rather than trusting the claim, and repeats the same 42 if the tracker still doesn't have it. `--resume` on any other type is refused |
| `touch --page "<Title>" [--summary "..."] [--provenance <v>] [--date YYYY-MM-DD] [--set field=value ...] [--add field=value ...] [--remove field=value ...] [--no-date] [--dry-run]` | Update a page's own frontmatter: bump `modified:` and optionally rewrite any field its type declares. `--summary`/`--provenance` are shorthands; `--set` reaches every other field and **replaces** its value, while `--add`/`--remove` change single elements of an array field (removing an absent element succeeds and says so). Repeating `--set` for one array field appends *within the call*, and `\,` is a literal comma - same rules as `new --set`. Refused with the command that owns them instead: `type:` (page-lifecycle), and the page-ref arrays `related:`/`sources:`/`entities:`/`concepts:` (`xref`). Everything else the schema declares is settable, and an unknown field lists what the page actually has. Schema-validates the fields it writes, and `raw_files:` entries must exist on disk. A source declares `date:` instead of `modified:`, and that is the *publication* date of the raw material - it is never bumped to today, and changes only when `--date` names a value explicitly. | | `touch --page "<Title>" [--summary "..."] [--provenance <v>] [--date YYYY-MM-DD] [--set field=value ...] [--add field=value ...] [--remove field=value ...] [--no-date] [--dry-run]` | Update a page's own frontmatter: bump `modified:` and optionally rewrite any field its type declares. `--summary`/`--provenance` are shorthands; `--set` reaches every other field and **replaces** its value, while `--add`/`--remove` change single elements of an array field (removing an absent element succeeds and says so). Repeating `--set` for one array field appends *within the call*, and `\,` is a literal comma - same rules as `new --set`. Refused with the command that owns them instead: `type:` (page-lifecycle), and the page-ref arrays `related:`/`sources:`/`entities:`/`concepts:` (`xref`). Everything else the schema declares is settable, and an unknown field lists what the page actually has. Schema-validates the fields it writes, and `raw_files:` entries must exist on disk. A source declares `date:` instead of `modified:`, and that is the *publication* date of the raw material - it is never bumped to today, and changes only when `--date` names a value explicitly. |
| `rename --from "<Old>" --to "<New>" [--dry-run]` | Rename a page and repoint every reference to it: body `[[wikilinks]]` (aliases and anchors preserved), a `[^cite-id]` whose id was derived from the old title (refreshed to match the new one, both in its Footnotes definition and every reference to it), the page's own H1, and every page-ref frontmatter array declared by the type's `page_ref_fields:`. If `--from` is *not* a page but is referenced, it instead repoints those references onto the existing `--to` page and moves nothing - the fix for a reference spelled `act_runner` when the page is `Act Runner` | | `rename --from "<Old>" --to "<New>" [--dry-run]` | Rename a page and repoint every reference to it: body `[[wikilinks]]` (aliases and anchors preserved), a `[^cite-id]` whose id was derived from the old title (refreshed to match the new one, both in its Footnotes definition and every reference to it), the page's own H1, and every page-ref frontmatter array declared by the type's `page_ref_fields:`. If `--from` is *not* a page but is referenced, it instead repoints those references onto the existing `--to` page and moves nothing - the fix for a reference spelled `act_runner` when the page is `Act Runner` |
| `rm --page "<Title>" [--yes] [--dry-run]` | Delete a page and mechanically de-link it. Refuses without `--yes` while other pages still reference it. Strips ref-array entries and bare `- [[Title]]` / `- **label:** [[Title]]` bullets; leaves prose and inline citations in place and reports them | | `rm --page "<Title>" [--yes] [--dry-run]` | Delete a page and mechanically de-link it. Refuses without `--yes` while other pages still reference it. Strips ref-array entries and bare `- [[Title]]` / `- **label:** [[Title]]` bullets; leaves prose and inline citations in place and reports them |
@@ -126,7 +126,7 @@ tools/wikitool <command> --help
|---------|---------| |---------|---------|
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, pages nested more than one directory below their collection (hard - the generated catalog folds these into their area silently rather than merely reading it), uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, unbalanced generated-region markers, edges whose label is missing or not authorised by the source collection's `outbound:` (both hard once `kb_version` has reached the release that introduced labelled edges - advisory below it, so a corpus mid-migration is not refused by the check measuring it), `see-also` edges whose reverse direction already carries a specific label (advisory only - redundant rather than wrong, and never migration-gated, since no version turns the redundancy into an error), a collection past the catalog's per-area shard threshold that has no areas to shard (advisory only - sharding is automatic but per *area*, so a collection nobody gave areas keeps one table however large it grows; reported with the split its subtype field would produce, and only when that split puts every resulting area at or under the threshold, so a lopsided or small collection stays silent), source pages sitting in the `unclassified` catalog slot (advisory only - `unclassified` is the visible fallback for a genuinely unclear source, not a defect), quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing | | `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, pages nested more than one directory below their collection (hard - the generated catalog folds these into their area silently rather than merely reading it), uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, unbalanced generated-region markers, edges whose label is missing or not authorised by the source collection's `outbound:` (both hard once `kb_version` has reached the release that introduced labelled edges - advisory below it, so a corpus mid-migration is not refused by the check measuring it), `see-also` edges whose reverse direction already carries a specific label (advisory only - redundant rather than wrong, and never migration-gated, since no version turns the redundancy into an error), a collection past the catalog's per-area shard threshold that has no areas to shard (advisory only - sharding is automatic but per *area*, so a collection nobody gave areas keeps one table however large it grows; reported with the split its subtype field would produce, and only when that split puts every resulting area at or under the threshold, so a lopsided or small collection stays silent), source pages sitting in the `unclassified` catalog slot (advisory only - `unclassified` is the visible fallback for a genuinely unclear source, not a defect), quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. One hit per line, ` | `-separated as `score \| kind/subtype \| title \| path \| summary`, so a hit can be judged without opening the page and then opened without looking it up: **title and path are never truncated** (the title is the identifier `touch`/`xref`/`cite` take), and the summary - the one lossy field, and the only one that may contain the separator - goes last, so splitting on `" \| "` with `maxsplit=4` is unambiguous. Scope is pages: the backend walks `kb/` but drops anything `kb_scan.iter_kb_pages` excludes (the kb-root meta files, every `COLLECTION.md`, every generated `INDEX.md`), which is why a hand-run grep over `kb/` can add none of them but those. `--limit` defaults to 50 (`0` for no limit) and **a truncated result says so** - `50 of 182 result(s)` in the table, `total`/`truncated`/`limit` beside `count` in `--json`, where `count` stays the number of results in the payload; the same default and the same fields are what `api.search` and the MCP `search` tool carry, from one constant. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** | | `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. One hit per line, ` | `-separated as `score \| kind/subtype \| title \| path \| summary`, so a hit can be judged without opening the page and then opened without looking it up: **title and path are never truncated** (the title is the identifier `touch`/`xref`/`cite` take), and the summary - the one lossy field, and the only one that may contain the separator - goes last, so splitting on `" \| "` with `maxsplit=4` is unambiguous. Scope is pages: the backend walks `kb/` but drops anything `kb_scan.iter_kb_pages` excludes (the kb-root meta files, every `COLLECTION.md`, every generated `INDEX.md`), which is why a hand-run grep over `kb/` can add none of them but those. `--limit` defaults to 50 (`0` for no limit) and **a truncated result says so** - `50 of 182 result(s)` in the table, `total`/`truncated`/`limit` beside `count` in `--json`, where `count` stays the number of results in the payload; the same default and the same fields are what `api.search` and the MCP `search` tool carry, from one constant. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** |
| `review [--json]` | The GTD weekly review: joins the configured task-tracker provider (`chemenu.tasks`) against `kb/gtd/` project pages over the case-normalized project name, at read time, storing nothing - not even a `reports/` file. Five checks: **stalled** (a tracker project with zero open items whose `kb/` page is `state: active` - `dormant`/`completed`/`abandoned` never fire, since those states mean the initiative not having a next action is expected rather than a problem), **waiting-overdue** (a `WAITING` item whose `follow_up_at` is older than `thresholds.stalled_waiting_days`), **unpaged-project** (a tracker project with no matching `kb/` page, older than `thresholds.unpaged_project_weeks`), **no-open-loop** (a `kb/` page `state: active` with no matching tracker project, or one with zero open items - the reverse direction of the unpaged-project join, so a rename on either side surfaces on both), **someday-stale** (a someday/maybe item untouched for longer than `thresholds.someday_stale_months`). Thresholds come from `.wikitool-tasks.json`, never from the schema. Text output is one `[check] project: message` line per finding; `--json` carries the same findings plus `checks_run`/`checks_skipped`/`kb_project_count`/`complete`. No `.wikitool-tasks.json` fails immediately with a clear "no tracker configured" message; a provider that cannot be reached mid-run degrades only the checks that needed the failing call, and the report is never rendered as if it were complete - see its error-contract row. Read-only, and **exempt from the Iteration Budget Gate** | | `review [--json]` | The GTD weekly review: joins the configured task-tracker provider (`chemenu.tasks`) against `kb/gtd/` project pages over the case-normalized project name, at read time, storing nothing - not even a `reports/` file. Five checks: **stalled** (a tracker project with zero open items whose `kb/` page is `state: active` - `dormant`/`completed`/`abandoned` never fire, since those states mean the initiative not having a next action is expected rather than a problem), **waiting-overdue** (a `WAITING` item whose `follow_up_at` is older than `thresholds.stalled_waiting_days`), **unpaged-project** (a tracker project with no matching `kb/` page, older than `thresholds.unpaged_project_weeks`), **no-open-loop** (a `kb/` page `state: active` with no matching tracker project, or one with zero open items - the reverse direction of the unpaged-project join, so a rename on either side surfaces on both), **someday-stale** (a someday/maybe item untouched for longer than `thresholds.someday_stale_months`). Thresholds come from `.wikitool-tasks.json`, never from the schema. Text output is one `[check] project: message` line per finding, preceded by a `Source:` line naming which access path answered and, for `superproductivity`'s `access: "snapshot"`, the snapshot's age; `--json` carries the same findings plus `checks_run`/`checks_skipped`/`kb_project_count`/`complete`/`source` (`{"kind": ..., "detail": ...}` or `null`). No `.wikitool-tasks.json` fails immediately with a clear "no tracker configured" message; a provider that cannot be reached mid-run degrades only the checks that needed the failing call, and the report is never rendered as if it were complete - see its error-contract row. Read-only, and **exempt from the Iteration Budget Gate** |
### Provenance ### Provenance
@@ -216,7 +216,7 @@ tools/wikitool <command> --help
| Command | Purpose | | Command | Purpose |
|---------|---------| |---------|---------|
| `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, whether the MCP `submit` tool is armed (`.wikitool-upload.json` present/absent/malformed, its limits, and how many submissions are waiting in `mcp-upload/` - absent is `OK` and means the write path does not exist at all, malformed is the one `FAIL` here, since a broken opt-in must not silently disable the limits it exists to enforce), the task-tracker provider (`.wikitool-tasks.json` present/absent/malformed - absent is `OK` and means no tracker is configured, malformed is `FAIL` for the same reason the upload opt-in is; for a configured `superproductivity` provider, also whether its read path (a backup snapshot) is ready and whether its local REST API answers `GET /health` right now - neither ever `FAIL`s, an app that is simply not running is not a fault), the session id source (`OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback - see `chemenu.session`), and telemetry state (on/off, why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session count/byte total against both caps; never `FAIL`, see [EVALS.md](../EVALS.md)). Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate | | `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, whether the MCP `submit` tool is armed (`.wikitool-upload.json` present/absent/malformed, its limits, and how many submissions are waiting in `mcp-upload/` - absent is `OK` and means the write path does not exist at all, malformed is the one `FAIL` here, since a broken opt-in must not silently disable the limits it exists to enforce), the task-tracker provider (`.wikitool-tasks.json` present/absent/malformed - absent is `OK` and means no tracker is configured, malformed is `FAIL` for the same reason the upload opt-in is; for a configured `superproductivity` provider, also its configured `access` path's own state - `access: "api"` reports whether its local REST API answers `GET /health` right now, `access: "snapshot"` reports whether a backup file is ready; the *other* access path is never attempted and is not a finding - and neither ever `FAIL`s, an app that is simply not running is not a fault), the session id source (`OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback - see `chemenu.session`), and telemetry state (on/off, why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session count/byte total against both caps; never `FAIL`, see [EVALS.md](../EVALS.md)). Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate |
## Design notes ## Design notes
@@ -310,7 +310,7 @@ is atomic, and whether a retry is safe.
| Command | Exit 1 means | Atomic? | Retry policy | | Command | Exit 1 means | Atomic? | Retry policy |
|---------|--------------|---------|--------------| |---------|--------------|---------|--------------|
| `new <type>` | Duplicate page title, unknown type, invalid `--set` value, or a `raw_files` path that doesn't exist | Yes - single file write | Not transient; fix the argument and retry once. Never hand-craft the page instead | | `new <type>` | Duplicate page title, unknown type, invalid `--set` value, or a `raw_files` path that doesn't exist | Yes - single file write | Not transient; fix the argument and retry once. Never hand-craft the page instead |
| `new project` | Everything `new <type>` covers, **plus**: the name is already taken in the tracker (case-insensitively), or `--resume` was passed for a type other than `project` | **No** for the tracker-configured case - a tracker-project write (or its human-clearance request) happens before the kb/ page write, so a failure between the two leaves a tracker project with no page (a state `review`'s check 3 already reports), never a page with no tracker project. Still a single file write when no tracker is configured | A collision or a bad `--set` is not transient, same as `new <type>`. **Exit 42** (`NEEDS USER CLEARANCE`, not exit 1) is its own third outcome: the provider cannot create the project itself and a human must, per the printed instructions; re-run with `--resume` once that is done - it re-verifies via the read path rather than trusting the claim, and exits 42 again unchanged if the tracker still does not have it | | `new project` | Everything `new <type>` covers, **plus**: the name is already taken in the tracker (case-insensitively), `--resume` was passed for a type other than `project`, or the configured provider's access path has no write path at all (Super Productivity's `access: "snapshot"`) | **No** for the tracker-configured case - a tracker-project write (or its human-clearance request) happens before the kb/ page write, so a failure between the two leaves a tracker project with no page (a state `review`'s check 3 already reports), never a page with no tracker project. Still a single file write when no tracker is configured | A collision, a bad `--set`, or a read-only access path is not transient, same as `new <type>` - the last of those points at the `access: "api"` instance instead and refuses on every `--resume` retry too, since nothing about the config changes by asking again. **Exit 42** (`NEEDS USER CLEARANCE`, not exit 1) is its own separate outcome from the ordinary exit-1 cases above: the provider *can* write but cannot create the project itself and a human must, per the printed instructions; re-run with `--resume` once that is done - it re-verifies via the read path rather than trusting the claim, and exits 42 again unchanged if the tracker still does not have it |
| `touch` | Page not found; an invalid value for a field it writes; a field owned by another command (`type:`, a page-ref array) or absent from the type's schema; `--add`/`--remove` on a non-array field; a `raw_files:` path that doesn't exist | Yes - single file write, and every refusal happens before it | Fix the argument and retry once. Safe to re-run as-is: `--set` and `--add` are idempotent, and `--remove` of an already-absent element succeeds while reporting it | | `touch` | Page not found; an invalid value for a field it writes; a field owned by another command (`type:`, a page-ref array) or absent from the type's schema; `--add`/`--remove` on a non-array field; a `raw_files:` path that doesn't exist | Yes - single file write, and every refusal happens before it | Fix the argument and retry once. Safe to re-run as-is: `--set` and `--add` are idempotent, and `--remove` of an already-absent element succeeds while reporting it |
| `rename` | Neither `--from` nor `--to` is a page, target title already taken, or `--from` equals `--to` | No - one write per referencing page, then the file move | Safe to retry once as-is; each page's rewrite is idempotent. Use `--dry-run` first to see the blast radius. Never fix up references by hand instead | | `rename` | Neither `--from` nor `--to` is a page, target title already taken, or `--from` equals `--to` | No - one write per referencing page, then the file move | Safe to retry once as-is; each page's rewrite is idempotent. Use `--dry-run` first to see the blast radius. Never fix up references by hand instead |
| `rm` | Page not found, **or** other pages still reference it and `--yes` was not passed | No - one write per referencing page, then the delete | For "still referenced": show the user the inbound list, get approval, then re-run with `--yes`. Prose references it reports afterwards are an editorial fix, not a retry | | `rm` | Page not found, **or** other pages still reference it and `--yes` was not passed | No - one write per referencing page, then the delete | For "still referenced": show the user the inbound list, get approval, then re-run with `--yes`. Prose references it reports afterwards are an editorial fix, not a retry |
+13 -2
View File
@@ -426,6 +426,11 @@ def check_tasks_provider() -> Check:
"nothing configured". Provider reachability itself never affects the "nothing configured". Provider reachability itself never affects the
exit code, same as `check_git_repo`'s remote check: the app being closed exit code, same as `check_git_repo`'s remote check: the app being closed
is normal, not a fault. is normal, not a fault.
For `superproductivity`, only the instance's configured `access` path is
ever attempted (Gitea #133) - `api` reports API reachability, `snapshot`
reports whether a backup file is ready; the other path is simply not a
finding, since this instance never touches it.
""" """
from chemenu import config from chemenu import config
from chemenu.errors import ValidationError from chemenu.errors import ValidationError
@@ -456,14 +461,20 @@ def check_tasks_provider() -> Check:
"tasks-provider", "FAIL", str(exc), "tasks-provider", "FAIL", str(exc),
f"Fix the 'superproductivity' section of {config.TASKS_CONFIG_FILENAME}", f"Fix the 'superproductivity' section of {config.TASKS_CONFIG_FILENAME}",
) )
# Only the configured access path is a finding (Gitea #133) - the
# other one is not attempted at all, so it has nothing to report.
if sp_cfg.access == sp.ACCESS_API:
api_state = "API reachable" if sp.health(sp_cfg) else "API not reachable (app not running?)"
return Check(
"tasks-provider", "OK", f"superproductivity: access=api; {api_state}",
)
try: try:
snapshot_path = sp.latest_snapshot_path(sp_cfg) snapshot_path = sp.latest_snapshot_path(sp_cfg)
read_state = f"read path OK, newest snapshot {rel_path(snapshot_path)}" read_state = f"read path OK, newest snapshot {rel_path(snapshot_path)}"
except ValidationError as exc: except ValidationError as exc:
read_state = f"read path not ready ({exc})" read_state = f"read path not ready ({exc})"
api_state = "API reachable" if sp.health(sp_cfg) else "API not reachable (app not running?)"
return Check( return Check(
"tasks-provider", "OK", f"superproductivity: {read_state}; {api_state}", "tasks-provider", "OK", f"superproductivity: access=snapshot; {read_state}",
) )
return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured") return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured")
+9 -1
View File
@@ -290,6 +290,15 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
return None return None
reader = tasks.build_reader(cfg) reader = tasks.build_reader(cfg)
try:
writer = tasks.build_writer(cfg, reader)
except ValidationError as exc:
# E.g. a superproductivity instance with access: "snapshot" (Gitea
# #133) - the tracker is read-only from here, so this refuses before
# either the collision check or the page write, exactly like any
# other precondition failure.
fail(str(exc))
existing = find_project(reader, page_title) existing = find_project(reader, page_title)
if existing is not None: if existing is not None:
if resume: if resume:
@@ -302,7 +311,6 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
"instead of being refused here." "instead of being refused here."
) )
writer = tasks.build_writer(cfg, reader)
try: try:
writer.create_project(page_title) writer.create_project(page_title)
except HumanInterventionRequired as exc: except HumanInterventionRequired as exc:
+7
View File
@@ -23,6 +23,8 @@ def render_report(report: ReviewReport) -> str:
message`, so a hit can be told apart from the summary line without a message`, so a hit can be told apart from the summary line without a
schema - the same shape `search`'s table takes for the same reason.""" schema - the same shape `search`'s table takes for the same reason."""
lines: list[str] = [] lines: list[str] = []
if report.source is not None:
lines.append(f"Source: {report.source.kind} ({report.source.detail})")
if report.checks_skipped: if report.checks_skipped:
lines.append("INCOMPLETE - the following check(s) did not run:") lines.append("INCOMPLETE - the following check(s) did not run:")
for check, reason in report.checks_skipped: for check, reason in report.checks_skipped:
@@ -59,6 +61,11 @@ def report_to_dict(report: ReviewReport) -> dict:
], ],
"kb_project_count": report.kb_project_count, "kb_project_count": report.kb_project_count,
"complete": report.complete, "complete": report.complete,
"source": (
{"kind": report.source.kind, "detail": report.source.detail}
if report.source is not None
else None
),
} }
+11 -1
View File
@@ -27,7 +27,7 @@ from chemenu import config, kb_scan
from chemenu.errors import ValidationError from chemenu.errors import ValidationError
from chemenu.tasks import build_reader from chemenu.tasks import build_reader
from chemenu.tasks import config as tasks_config from chemenu.tasks import config as tasks_config
from chemenu.tasks.protocol import OpenItems, normalize_project_name from chemenu.tasks.protocol import OpenItems, ReadSource, normalize_project_name
CHECK_STALLED = "stalled" CHECK_STALLED = "stalled"
CHECK_WAITING_OVERDUE = "waiting_overdue" CHECK_WAITING_OVERDUE = "waiting_overdue"
@@ -61,6 +61,7 @@ class ReviewReport:
checks_run: tuple[str, ...] checks_run: tuple[str, ...]
checks_skipped: tuple[tuple[str, str], ...] # (check, reason) checks_skipped: tuple[tuple[str, str], ...] # (check, reason)
kb_project_count: int kb_project_count: int
source: Optional[ReadSource]
@property @property
def complete(self) -> bool: def complete(self) -> bool:
@@ -128,6 +129,14 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
reader = build_reader(cfg) reader = build_reader(cfg)
kb_projects = _load_kb_projects(Path(root) / "kb") kb_projects = _load_kb_projects(Path(root) / "kb")
try:
source: Optional[ReadSource] = reader.source()
except ValidationError:
# The same read that would answer this has already failed, or is
# about to below - `source` degrading to `None` here is no worse
# than the check it would have described also being skipped.
source = None
findings: list[Finding] = [] findings: list[Finding] = []
checks_run: list[str] = [] checks_run: list[str] = []
checks_skipped: list[tuple[str, str]] = [] checks_skipped: list[tuple[str, str]] = []
@@ -248,4 +257,5 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
checks_run=tuple(checks_run), checks_run=tuple(checks_run),
checks_skipped=tuple(checks_skipped), checks_skipped=tuple(checks_skipped),
kb_project_count=len(kb_projects), kb_project_count=len(kb_projects),
source=source,
) )
+20 -2
View File
@@ -24,11 +24,16 @@ from chemenu.tasks.protocol import TaskReader, TaskWriter
def build_reader(cfg: TasksConfig) -> TaskReader: def build_reader(cfg: TasksConfig) -> TaskReader:
"""Dispatch on `cfg.provider` to a concrete `TaskReader`.""" """Dispatch on `cfg.provider` to a concrete `TaskReader`. For
`superproductivity` the concrete class also depends on
`access` (Gitea #133): `"api"` reads the live local REST API,
`"snapshot"` reads the backup file - never both, never a fallback."""
if cfg.provider == "superproductivity": if cfg.provider == "superproductivity":
from chemenu.tasks import superproductivity as sp from chemenu.tasks import superproductivity as sp
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config) sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
if sp_cfg.access == sp.ACCESS_API:
return sp.SuperProductivityApiReader(sp_cfg)
return sp.SuperProductivityReader(sp_cfg) return sp.SuperProductivityReader(sp_cfg)
raise ValidationError(f"No reader is wired up for task provider {cfg.provider!r}.") raise ValidationError(f"No reader is wired up for task provider {cfg.provider!r}.")
@@ -37,10 +42,23 @@ def build_writer(cfg: TasksConfig, reader: TaskReader) -> TaskWriter:
"""Dispatch on `cfg.provider` to a concrete `TaskWriter`, over an """Dispatch on `cfg.provider` to a concrete `TaskWriter`, over an
already-built `reader` - a writer that needs to re-check the read path already-built `reader` - a writer that needs to re-check the read path
(e.g. `SuperProductivityWriter`'s own collision preflight) reads through (e.g. `SuperProductivityWriter`'s own collision preflight) reads through
the same object its caller does, rather than opening a second one.""" the same object its caller does, rather than opening a second one.
For `superproductivity`, a writer exists only when `access: "api"`
(Gitea #133): on `access: "snapshot"` the tracker is read-only from here
by construction, so this raises `ValidationError` rather than returning a
writer that could never do anything - the same posture as "no writer is
wired up for this provider at all", just scoped to one access mode of
one provider instead of the whole provider."""
if cfg.provider == "superproductivity": if cfg.provider == "superproductivity":
from chemenu.tasks import superproductivity as sp from chemenu.tasks import superproductivity as sp
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config) sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
if sp_cfg.access != sp.ACCESS_API:
raise ValidationError(
"superproductivity: the tracker is read-only from here (access: "
f"'{sp_cfg.access}') - the write path only exists on an access: 'api' "
"instance (Gitea #133)."
)
return sp.SuperProductivityWriter(sp_cfg, reader) return sp.SuperProductivityWriter(sp_cfg, reader)
raise ValidationError(f"No writer is wired up for task provider {cfg.provider!r}.") raise ValidationError(f"No writer is wired up for task provider {cfg.provider!r}.")
+3 -2
View File
@@ -66,8 +66,9 @@ def read_config(root: "Any") -> "TasksConfig | None":
expected = ( expected = (
'{"schema": 1, "provider": "superproductivity", ' '{"schema": 1, "provider": "superproductivity", '
'"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, ' '"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, '
'"someday_stale_months": 5}, "superproductivity": {"backups_dir": "...", ' '"someday_stale_months": 5}, "superproductivity": {"access": "api", '
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."}}' '"api_base_url": "http://127.0.0.1:3876", "api_token": "..."} '
'(or {"access": "snapshot", "backups_dir": "..."} - see INSTALL.md)}'
) )
try: try:
provider = str(data["provider"]) provider = str(data["provider"])
+46 -1
View File
@@ -15,7 +15,19 @@ 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 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()` 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`) after a human's out-of-band step (see `chemenu.errors.HumanInterventionRequired`)
sees the current state, not a snapshot from before that step. never sees a value this process cached from before that step.
**Re-reading is not the same as reading the current state** (Gitea #134,
resolved by #133's design rather than by a fix here): a point-in-time source
- Super Productivity's `access: "snapshot"`, say - re-reads the *latest file
on disk* on every call, which is only as current as that file's own age; a
caller's `verify()` can still answer "not yet" against a step that already
happened, if nothing has written a fresher file since. Only a genuinely live
source - `access: "api"` - re-reads the actual current state. A `TaskReader`
that is not always live should say so through `source()`
(`chemenu.tasks.protocol.ReadSource`), so a caller can tell "re-read, but
possibly stale" apart from "re-read, and current" instead of assuming the
stronger claim for every provider.
""" """
from __future__ import annotations from __future__ import annotations
@@ -43,12 +55,36 @@ class WaitingItem:
Faelligkeitsdatum") - a provider that has no separate concept for this Faelligkeitsdatum") - a provider that has no separate concept for this
must not fall back to reusing the due date, it must decide it cannot must not fall back to reusing the due date, it must decide it cannot
supply the field and leave it `None` instead. supply the field and leave it `None` instead.
**This rule binds the concept, not a field's name** (Gitea #135's own
correction, after #124's Super Productivity adapter read the wrong field
under this exact rule): a provider whose own vocabulary does not line up
with "due date" - a field called `due*` that actually means scheduling
rather than a deadline, say - must be checked against what its
documentation says the field *means*, not against what its name suggests
to an outsider. Getting this backwards produced a real bug: a whole class
of tickler (an all-day, notification-free follow-up) silently never
counted as `follow_up_at` at all.
""" """
title: str title: str
follow_up_at: Optional[date] follow_up_at: Optional[date]
@dataclass(frozen=True)
class ReadSource:
"""Where one `TaskReader.source()` call's data came from, for display
only (Gitea #133) - `chemenu.review`/`wikitool doctor` show it, no check
branches on it. `kind` is provider-defined (e.g. `"api"`/`"snapshot"` for
Super Productivity); `detail` is the human-readable line, which for a
point-in-time source (a snapshot file, not a live call) names its age -
the two access paths can live on different machines and nobody may ever
see them side by side, so the answer itself has to say how fresh it is."""
kind: str
detail: str
@dataclass(frozen=True) @dataclass(frozen=True)
class OpenItems: class OpenItems:
"""A project's momentary open-loop count, plus the subset that is """A project's momentary open-loop count, plus the subset that is
@@ -99,6 +135,15 @@ class TaskReader(Protocol):
projects.""" projects."""
... ...
def source(self) -> ReadSource:
"""Where this reader's data comes from, for display (Gitea #133) -
never consulted by a check, only by `chemenu.review`/`wikitool doctor`
to say which path answered and, for a point-in-time source, how old
it is. Must not perform a network call beyond what answering it
cheaply requires - a provider whose read path is always live can
answer this without touching the network at all."""
...
class TaskWriter(Protocol): class TaskWriter(Protocol):
"""The write path a provider adapter offers only if it actually can """The write path a provider adapter offers only if it actually can
+319 -93
View File
@@ -1,57 +1,87 @@
"""The Super Productivity adapter (Gitea #124, #119 D2/D8/D9/D30). """The Super Productivity adapter (Gitea #124, #133, #135; #119 D2/D8/D9/D30).
Read and write deliberately use different transports (#124's own design note, Read and write access are chosen **per instance, explicitly, exclusively**
carried as its first acceptance criterion): the read path is headless file (Gitea #133): a headless-operated instance sets `access: "snapshot"` and only
access, the write path is Super Productivity's own local REST API - so a ever reads the periodic backup file on disk; a desktop instance sets
review can run with the app closed, and a create only needs the app open when `access: "api"` and only ever talks to Super Productivity's own local REST
it actually has to ask it for something. API, which also carries the current live state and the one write call this
adapter offers. There is no third value, no default, and no runtime fallback
between the two - the config decides once, at read time, which half of this
module ever runs.
## Read path: the backup snapshot, not a live `db.json` ## `access: "snapshot"` - the backup file, not a live `db.json`
Desktop Super Productivity keeps its live state in IndexedDB, not in a flat 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. 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` What *does* exist as a plain file is a periodic snapshot: `electron/backup.ts`
writes the complete app state as `JSON.stringify(data)` into writes the complete app state as `JSON.stringify(data)` into
`<userData>/backups/<timestamp>.json` on every backup, newest-timestamp-last `<userData>/backups/YYYY-MM-DD_HHmmss.json` on every backup - a fixed-width
by filename (its own comment: "timestamps sort lexically"). That snapshot's timestamp name, so a lexical sort is also a chronological one, which is what
top-level shape is `AppDataComplete`/`AppDataCompleteLegacy` `latest_snapshot_path` relies on. That snapshot's top-level shape is
(`src/app/op-log/model/model-config.ts` / `src/app/imex/sync/sync.model.ts`), `AppDataComplete`/`AppDataCompleteLegacy` (`src/app/op-log/model/model-config.ts`
keyed by feature name - `task`, `project`, `tag`, ... - and this reader only / `src/app/imex/sync/sync.model.ts`), keyed by feature name - `task`,
looks at the three keys it needs, each an `@ngrx/entity` `EntityState` `project`, `tag`, ... - and this reader only looks at the three keys it
(`{"ids": [...], "entities": {...}}`, `packages/plugin-api/src/types.ts` needs, each an `@ngrx/entity` `EntityState` (`{"ids": [...], "entities": {...}}`,
`Task`/`Project`/`Tag`). Verified against the `master` branch of `packages/plugin-api/src/types.ts` `Task`/`Project`/`Tag`). Unversioned, per
`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 -
#124's own note, so a future release is free to reshape it without warning, every read below fails loudly on a shape it does not recognize rather than
which is exactly why every read below fails loudly on a shape it does not guessing.
recognize rather than guessing.
## The two GTD conventions this adapter encodes (#119 D9/D30) ## `access: "api"` - the local REST API, the current live state
The routes live in the renderer, not the Electron main process:
`src/app/core/electron/local-rest-api-handler.service.ts` registers
`GET /status`, `GET /focus`, `GET|POST /task-control/*`, `GET|POST /tasks`,
`GET|PATCH|DELETE /tasks/:id`, `GET /projects`, `GET /tags` - project and tag
CRUD do not exist. Every endpoint but `GET /health` requires
`Authorization: Bearer <api_token>`; a closed app or an unauthenticated
request are not the same failure - `GET /health` answers `503 APP_NOT_READY`
when the backend is up but the renderer is not yet, which this adapter
surfaces as its own message rather than folding into "unreachable".
`GET /projects` runs through `selectUnarchivedProjects` and excludes
`isArchived` projects server-side; the snapshot path below does the same
filtering itself, so the two access paths agree on that without either one
needing to know how the other got there (verified against `master`,
2026-09-20).
## The two GTD conventions this adapter encodes (#119 D9/D30, corrected #135)
Only the `WAITING` status and `follow_up_at` are machine-readable, and Super Only the `WAITING` status and `follow_up_at` are machine-readable, and Super
Productivity has no native field for either: Productivity has no native field for either:
- **`WAITING`** is a tag named `waiting` (case-insensitively), attached to the - **`WAITING`** is a tag named `waiting` (case-insensitively), attached to the
task. Any other tag is left alone. task. Any other tag is left alone.
- **`follow_up_at`** is the task's own `remindAt` (a reminder timestamp) - - **`follow_up_at`** is the task's own *scheduled* date - `dueWithTime` if
deliberately not `dueDay`/`dueWithTime` (#119 D9: "ausdruecklich nicht das set, else `dueDay` (`task.model.ts`'s own read rule: "check dueWithTime
Faelligkeitsdatum"). A task with no reminder set has no `follow_up_at`, full FIRST"). This is **not** the earlier `remindAt` mapping from #124: `remindAt`
stop; this adapter never substitutes the due date for it. only exists when a task is scheduled with a specific time *and* someone
asked for a notification, so an all-day, notification-free tickler carried
no `follow_up_at` at all under that mapping - a real gap #135 closed.
`deadlineDay`/`deadlineWithTime`/`deadlineRemindAt` are Super Productivity's
actual due-date fields (its own model docstrings say so) and are never read
here (#119 D9 continues to exclude them) - the point of #135's correction
is that `due*` was never the thing D9 excludes, whatever the name suggests.
A task with neither `dueWithTime` nor `dueDay` has no `follow_up_at`, full
stop; this adapter never substitutes the deadline for it.
## Someday/Maybe: a project's own backlog ## Someday/Maybe: a project's own backlog
#124 (later, #119) left this as "zu verifizieren": Super Productivity's Super Productivity's `ProjectBasicCfg.backlogTaskIds` is exactly this - a
`ProjectBasicCfg.backlogTaskIds` is exactly this - a second, separate list of second, separate list of task ids per project, apart from the active
task ids per project, apart from the active `taskIds` list a project's board `taskIds` list a project's board shows. This adapter reads someday/maybe
shows. This adapter reads someday/maybe items from `backlogTaskIds`, one items from `backlogTaskIds`, one project at a time; no tag convention is
project at a time; no tag convention is needed. needed.
## Write path: no project-creation endpoint exists ## Write path: no project-creation endpoint exists, on either access mode
The local REST API (`electron/local-rest-api-handler.service.ts`, verified the Neither transport routes `POST /projects` - task CRUD exists, project CRUD
same day) routes `GET /projects` but has no `POST /projects` at all - task does not, verified the same day as the rest of this module. So
CRUD exists, project CRUD does not. `SuperProductivityWriter.create_project` `SuperProductivityWriter.create_project` can never create a project itself
can therefore not create a project itself; see its docstring and regardless of `access`; see its docstring and
`chemenu.errors.HumanInterventionRequired`. `chemenu.errors.HumanInterventionRequired`. A writer is offered at all only
when `access: "api"` - see `chemenu.tasks.build_writer` - because on
`access: "snapshot"` the tracker is read-only from here by construction, not
by an extra check bolted onto this module (Gitea #133).
""" """
from __future__ import annotations from __future__ import annotations
@@ -67,6 +97,7 @@ from chemenu.errors import HumanInterventionRequired, ValidationError
from chemenu.tasks.protocol import ( from chemenu.tasks.protocol import (
OpenItems, OpenItems,
ProjectSummary, ProjectSummary,
ReadSource,
SomedayItem, SomedayItem,
WaitingItem, WaitingItem,
find_project, find_project,
@@ -79,82 +110,119 @@ WAITING_TAG_TITLE = "waiting"
DEFAULT_API_BASE_URL = "http://127.0.0.1:3876" DEFAULT_API_BASE_URL = "http://127.0.0.1:3876"
_EXPECTED_PROVIDER_CONFIG = ( ACCESS_API = "api"
'{"backups_dir": "~/.config/superProductivity/backups", ' ACCESS_SNAPSHOT = "snapshot"
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."}' KNOWN_ACCESS = (ACCESS_API, ACCESS_SNAPSHOT)
' (or "db_path" instead of "backups_dir" to pin one exact file)'
# `electron/backup.ts` writes exactly this shape - a fixed-width timestamp,
# no prefix. Restricting the glob to it (Gitea #133) is what keeps a manually
# exported file (`sp-backup_*.json` and friends, which sort *after* every
# timestamp lexically) from ever being picked as "newest".
_SNAPSHOT_GLOB = (
"[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]_"
"[0-9][0-9][0-9][0-9][0-9][0-9].json"
) )
_EXPECTED_API_CONFIG = '{"access": "api", "api_base_url": "http://127.0.0.1:3876", "api_token": "..."}'
_EXPECTED_SNAPSHOT_CONFIG = '{"access": "snapshot", "backups_dir": "~/.config/superProductivity/backups"}'
@dataclass(frozen=True) @dataclass(frozen=True)
class SuperProductivityConfig: class SuperProductivityConfig:
"""This provider's own section of `.wikitool-tasks.json` """This provider's own section of `.wikitool-tasks.json`
(`TasksConfig.provider_config`).""" (`TasksConfig.provider_config`). `access` decides both halves at once -
which path is read *and* whether a write path exists at all - and the
section carries only the fields that access path uses (Gitea #133): a
`snapshot` config with an `api_base_url` in it, or an `api` config with a
`backups_dir` in it, is rejected at read time, not ignored."""
# Exactly one of these two names where to read from. `db_path` wins when access: str
# both are set - it names one exact file, which is a stronger statement
# than "the newest file in this directory".
backups_dir: Optional[Path] backups_dir: Optional[Path]
db_path: Optional[Path]
api_base_url: str 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] api_token: Optional[str]
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any]) -> "SuperProductivityConfig": def from_dict(cls, data: dict[str, Any]) -> "SuperProductivityConfig":
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValidationError( raise ValidationError(
f"superproductivity config must be an object. Expected: {_EXPECTED_PROVIDER_CONFIG}" "superproductivity config must be an object. Expected one of: "
f"{_EXPECTED_API_CONFIG} or {_EXPECTED_SNAPSHOT_CONFIG}"
) )
backups_dir = data.get("backups_dir") access = data.get("access")
db_path = data.get("db_path") if access not in KNOWN_ACCESS:
if backups_dir is None and db_path is None:
raise ValidationError( raise ValidationError(
"superproductivity config needs 'backups_dir' or 'db_path' - the read path has " "superproductivity config needs 'access', either 'api' or 'snapshot' - "
f"nothing to read otherwise. Expected: {_EXPECTED_PROVIDER_CONFIG}" "required, no default and no fallback between them (Gitea #133). Got: "
f"{access!r}. Expected one of: {_EXPECTED_API_CONFIG} or {_EXPECTED_SNAPSHOT_CONFIG}"
)
if access == ACCESS_API:
extra = sorted(set(data) - {"access", "api_base_url", "api_token"})
if extra:
raise ValidationError(
f"superproductivity config: access: 'api' does not take {extra} - a "
"section names only one access path's own fields (Gitea #133). "
f"Expected: {_EXPECTED_API_CONFIG}"
)
api_token = data.get("api_token")
if not isinstance(api_token, str) or not api_token:
raise ValidationError(
"superproductivity config: api_token is required when access: 'api' - "
"every endpoint but GET /health requires Authorization: Bearer <token>. "
f"Expected: {_EXPECTED_API_CONFIG}"
) )
api_base_url = str(data.get("api_base_url") or DEFAULT_API_BASE_URL) api_base_url = str(data.get("api_base_url") or DEFAULT_API_BASE_URL)
api_token = data.get("api_token") return cls(access=access, backups_dir=None, api_base_url=api_base_url, api_token=api_token)
if api_token is not None and not isinstance(api_token, str):
raise ValidationError("superproductivity config: api_token must be a string.") extra = sorted(set(data) - {"access", "backups_dir"})
if extra:
raise ValidationError(
f"superproductivity config: access: 'snapshot' does not take {extra} - a "
"section names only one access path's own fields (Gitea #133). "
f"Expected: {_EXPECTED_SNAPSHOT_CONFIG}"
)
backups_dir = data.get("backups_dir")
if not backups_dir:
raise ValidationError(
"superproductivity config: backups_dir is required when access: 'snapshot' - "
f"the read path has nothing to read otherwise. Expected: {_EXPECTED_SNAPSHOT_CONFIG}"
)
return cls( return cls(
backups_dir=Path(backups_dir).expanduser() if backups_dir else None, access=access,
db_path=Path(db_path).expanduser() if db_path else None, backups_dir=Path(backups_dir).expanduser(),
api_base_url=api_base_url, api_base_url=DEFAULT_API_BASE_URL,
api_token=api_token, api_token=None,
) )
def latest_snapshot_path(cfg: SuperProductivityConfig) -> Path: def latest_snapshot_path(cfg: SuperProductivityConfig) -> Path:
"""The one file to read: `db_path` if given, else the lexically-greatest """The lexically-greatest `YYYY-MM-DD_HHmmss.json` filename under
`*.json` filename under `backups_dir` - the same ordering `backups_dir` - the same ordering `electron/backup.ts` writes by
`electron/backup.ts` itself relies on ("timestamps sort lexically").""" construction. Only files matching that exact pattern are candidates
if cfg.db_path is not None: (Gitea #133): a manual export (`sp-backup_*.json` and its variants) sorts
if not cfg.db_path.is_file(): lexically *after* every timestamp and would otherwise pin every reader to
raise ValidationError( itself forever."""
f"superproductivity: db_path does not exist: {cfg.db_path}"
)
return cfg.db_path
directory = cfg.backups_dir directory = cfg.backups_dir
assert directory is not None # from_dict guarantees at least one is set assert directory is not None # from_dict guarantees this for access: snapshot
if not directory.is_dir(): if not directory.is_dir():
raise ValidationError( raise ValidationError(
f"superproductivity: backups_dir does not exist: {directory}" f"superproductivity: backups_dir does not exist: {directory}"
) )
candidates = sorted(directory.glob("*.json")) candidates = sorted(directory.glob(_SNAPSHOT_GLOB))
if not candidates: if not candidates:
raise ValidationError( raise ValidationError(
f"superproductivity: no *.json backup file found under {directory}. Take a " f"superproductivity: no timestamped backup file (YYYY-MM-DD_HHmmss.json) found "
"backup from Super Productivity (Settings -> Backup & Sync -> Local backups), " f"under {directory}. Take a backup from Super Productivity (Settings -> Backup & "
"or point db_path/backups_dir at where it actually writes them." "Sync -> Local backups), or point backups_dir at where it actually writes them."
) )
return candidates[-1] return candidates[-1]
def _snapshot_age_days(path: Path) -> int:
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
return (datetime.now(tz=timezone.utc) - mtime).days
def _load_snapshot(cfg: SuperProductivityConfig) -> dict[str, Any]: def _load_snapshot(cfg: SuperProductivityConfig) -> dict[str, Any]:
path = latest_snapshot_path(cfg) path = latest_snapshot_path(cfg)
try: try:
@@ -194,10 +262,37 @@ def _epoch_ms_to_date(value: Any) -> Optional[date]:
return datetime.fromtimestamp(value / 1000, tz=timezone.utc).date() return datetime.fromtimestamp(value / 1000, tz=timezone.utc).date()
def _iso_day_to_date(value: Any) -> Optional[date]:
if not isinstance(value, str):
return None
try:
return date.fromisoformat(value)
except ValueError:
return None
def _follow_up_at(task: dict) -> Optional[date]:
"""`follow_up_at` per #135's corrected mapping: `dueWithTime` first (Super
Productivity's own read rule - it takes priority over `dueDay`), else
`dueDay`. Never `deadline*` (#119 D9) and never the old `remindAt`."""
due_with_time = _epoch_ms_to_date(task.get("dueWithTime"))
if due_with_time is not None:
return due_with_time
return _iso_day_to_date(task.get("dueDay"))
def _is_waiting(task: dict, tags_by_id: dict[str, dict]) -> bool:
for tag_id in task.get("tagIds") or []:
tag = tags_by_id.get(tag_id)
if tag and str(tag.get("title", "")).strip().casefold() == WAITING_TAG_TITLE:
return True
return False
class SuperProductivityReader: class SuperProductivityReader:
"""`TaskReader` over a Super Productivity backup snapshot. Re-reads the """`TaskReader` over a Super Productivity backup snapshot
snapshot on every call - see `chemenu.errors.HumanInterventionRequired` (`access: "snapshot"`). Re-reads the snapshot on every call - see
for why that matters.""" `chemenu.errors.HumanInterventionRequired` for why that matters."""
def __init__(self, cfg: SuperProductivityConfig): def __init__(self, cfg: SuperProductivityConfig):
self._cfg = cfg self._cfg = cfg
@@ -208,6 +303,11 @@ class SuperProductivityReader:
projects = _entity_state(snapshot, "project", path) projects = _entity_state(snapshot, "project", path)
tasks = _entity_state(snapshot, "task", path) tasks = _entity_state(snapshot, "task", path)
tags = _entity_state(snapshot, "tag", path) tags = _entity_state(snapshot, "tag", path)
# Both access paths exclude archived projects (Gitea #133) - the API
# does it server-side (`selectUnarchivedProjects`), this path mirrors
# it explicitly so the two agree without either knowing about the
# other.
projects = {pid: p for pid, p in projects.items() if not p.get("isArchived")}
return path, projects, tasks, tags return path, projects, tasks, tags
def projects(self) -> list[ProjectSummary]: def projects(self) -> list[ProjectSummary]:
@@ -220,13 +320,6 @@ class SuperProductivityReader:
for record in projects.values() 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: def open_items(self, project_name: str) -> OpenItems:
_, projects, tasks, tags = self._read() _, projects, tasks, tags = self._read()
target = normalize_project_name(project_name) target = normalize_project_name(project_name)
@@ -244,12 +337,9 @@ class SuperProductivityReader:
if task is None or task.get("isDone"): if task is None or task.get("isDone"):
continue continue
count += 1 count += 1
if self._is_waiting(task, tags): if _is_waiting(task, tags):
waiting.append( waiting.append(
WaitingItem( WaitingItem(title=str(task.get("title", "")), follow_up_at=_follow_up_at(task))
title=str(task.get("title", "")),
follow_up_at=_epoch_ms_to_date(task.get("remindAt")),
)
) )
return OpenItems(count=count, waiting=tuple(waiting)) return OpenItems(count=count, waiting=tuple(waiting))
@@ -269,6 +359,14 @@ class SuperProductivityReader:
) )
return items return items
def source(self) -> ReadSource:
path = latest_snapshot_path(self._cfg)
age = _snapshot_age_days(path)
return ReadSource(
kind=ACCESS_SNAPSHOT,
detail=f"snapshot {path.name}, {age} day(s) old",
)
def health(cfg: SuperProductivityConfig, *, timeout: float = 2.0) -> bool: def health(cfg: SuperProductivityConfig, *, timeout: float = 2.0) -> bool:
"""Whether the local REST API answers `GET /health` right now - the one """Whether the local REST API answers `GET /health` right now - the one
@@ -283,11 +381,139 @@ def health(cfg: SuperProductivityConfig, *, timeout: float = 2.0) -> bool:
return False return False
def _expect_list(value: Any, what: str) -> list[dict]:
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
raise ValidationError(
f"superproductivity: API {what} did not return a list of objects - the response "
"shape does not match what this adapter expects."
)
return value
class _ApiClient:
"""The one HTTP transport `SuperProductivityApiReader` uses - a thin,
loudly-failing wrapper, not a general REST client."""
def __init__(self, cfg: SuperProductivityConfig):
self._cfg = cfg
def get(self, path: str, *, timeout: float = 10.0) -> Any:
url = self._cfg.api_base_url.rstrip("/") + path
request = urllib.request.Request(
url, headers={"Authorization": f"Bearer {self._cfg.api_token}"}
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
body = response.read()
except urllib.error.HTTPError as exc:
if exc.code == 503:
raise ValidationError(
"superproductivity: API answered 503 APP_NOT_READY for "
f"{path} - the app's backend is up but its renderer is not ready yet. "
"Wait a moment and retry."
) from exc
raise ValidationError(
f"superproductivity: API returned HTTP {exc.code} for {path}."
) from exc
except (urllib.error.URLError, OSError) as exc:
raise ValidationError(
f"superproductivity: API not reachable at {self._cfg.api_base_url} ({exc}). "
"Is Super Productivity running?"
) from exc
try:
return json.loads(body)
except json.JSONDecodeError as exc:
raise ValidationError(
f"superproductivity: API returned unparseable JSON for {path}."
) from exc
class SuperProductivityApiReader:
"""`TaskReader` over the local REST API (`access: "api"`) - the current
live state, re-fetched on every call. `GET /projects` already excludes
archived projects server-side; open items are counted against
`project.taskIds` rather than `GET /tasks?projectId=`, because a subtask
inherits its parent's `projectId` and would otherwise be double-counted
against that filter (Gitea #133) - the same source of truth
`SuperProductivityReader` uses on the snapshot side."""
def __init__(self, cfg: SuperProductivityConfig):
self._cfg = cfg
self._client = _ApiClient(cfg)
def _read(self) -> tuple[list[dict], dict[str, dict], dict[str, dict]]:
projects = _expect_list(self._client.get("/projects"), "/projects")
tasks = _expect_list(self._client.get("/tasks"), "/tasks")
tags = _expect_list(self._client.get("/tags"), "/tags")
# `GET /projects` already runs through `selectUnarchivedProjects`
# server-side - filtered again here so both access paths hold the
# same guarantee (Gitea #133) rather than one of them trusting the
# other end to have done it.
projects = [p for p in projects if not p.get("isArchived")]
tasks_by_id = {t["id"]: t for t in tasks if isinstance(t.get("id"), str)}
tags_by_id = {t["id"]: t for t in tags if isinstance(t.get("id"), str)}
return projects, tasks_by_id, tags_by_id
def projects(self) -> list[ProjectSummary]:
projects, _, _ = self._read()
return [
ProjectSummary(name=str(p.get("title", "")), created=_epoch_ms_to_date(p.get("created")))
for p in projects
]
def open_items(self, project_name: str) -> OpenItems:
projects, tasks_by_id, tags_by_id = self._read()
target = normalize_project_name(project_name)
project = next(
(p for p in projects 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_by_id.get(task_id)
if task is None or task.get("isDone"):
continue
count += 1
if _is_waiting(task, tags_by_id):
waiting.append(
WaitingItem(title=str(task.get("title", "")), follow_up_at=_follow_up_at(task))
)
return OpenItems(count=count, waiting=tuple(waiting))
def someday_items(self) -> list[SomedayItem]:
projects, tasks_by_id, _ = self._read()
items: list[SomedayItem] = []
for project in projects:
for task_id in project.get("backlogTaskIds") or []:
task = tasks_by_id.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 source(self) -> ReadSource:
# Static by construction - every call above re-fetches, so there is
# nothing "live" needs to check first, and no network call is spent
# just to answer this.
return ReadSource(kind=ACCESS_API, detail="live (local REST API)")
class SuperProductivityWriter: class SuperProductivityWriter:
"""`TaskWriter` over the local REST API - except there is no API call """`TaskWriter` over the local REST API - except there is no API call
this can actually make, see the module docstring.""" this can actually make, see the module docstring. Only offered by
`chemenu.tasks.build_writer` when `access: "api"` (Gitea #133) - on
`access: "snapshot"` the tracker is read-only from here, and that refusal
happens before this class is ever constructed."""
def __init__(self, cfg: SuperProductivityConfig, reader: SuperProductivityReader): def __init__(self, cfg: SuperProductivityConfig, reader):
self._cfg = cfg self._cfg = cfg
self._reader = reader self._reader = reader
+24 -1
View File
@@ -371,6 +371,8 @@ def test_tasks_provider_fails_on_a_bad_superproductivity_section(instance):
def test_tasks_provider_ok_but_names_the_unready_read_path_when_configured(instance): def test_tasks_provider_ok_but_names_the_unready_read_path_when_configured(instance):
"""`access: snapshot` - only the read path is a finding; the API is not
even attempted (Gitea #133)."""
backups = config.ROOT / "sp-backups" backups = config.ROOT / "sp-backups"
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text( (config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
json.dumps({ json.dumps({
@@ -379,14 +381,35 @@ def test_tasks_provider_ok_but_names_the_unready_read_path_when_configured(insta
"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "stalled_waiting_days": 14, "unpaged_project_weeks": 3,
"someday_stale_months": 5, "someday_stale_months": 5,
}, },
"superproductivity": {"backups_dir": str(backups)}, "superproductivity": {"access": "snapshot", "backups_dir": str(backups)},
}), }),
encoding="utf-8", encoding="utf-8",
) )
checks = doctor.run_doctor() checks = doctor.run_doctor()
assert _status(checks, "tasks-provider") == "OK" assert _status(checks, "tasks-provider") == "OK"
detail = _detail(checks, "tasks-provider") detail = _detail(checks, "tasks-provider")
assert "access=snapshot" in detail
assert "not ready" in detail assert "not ready" in detail
def test_tasks_provider_ok_but_names_api_unreachable_when_configured(instance):
"""`access: api` - the API reachability is the finding; the (irrelevant)
backup file is not even looked at (Gitea #133)."""
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
json.dumps({
"schema": 1, "provider": "superproductivity",
"thresholds": {
"stalled_waiting_days": 14, "unpaged_project_weeks": 3,
"someday_stale_months": 5,
},
"superproductivity": {"access": "api", "api_base_url": "http://127.0.0.1:1", "api_token": "t"},
}),
encoding="utf-8",
)
checks = doctor.run_doctor()
assert _status(checks, "tasks-provider") == "OK"
detail = _detail(checks, "tasks-provider")
assert "access=api" in detail
assert "not reachable" in detail assert "not reachable" in detail
+143 -26
View File
@@ -1,4 +1,8 @@
import contextlib
import http.server
import json import json
import threading
from pathlib import Path
from chemenu.commands._util import coerce_set_value, parse_set_fields from chemenu.commands._util import coerce_set_value, parse_set_fields
from chemenu.commands.new_page import _page_subdir from chemenu.commands.new_page import _page_subdir
@@ -12,34 +16,50 @@ _TASKS_THRESHOLDS = {
"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5, "stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5,
} }
_API_TOKEN = "test-token"
def _write_tasks_config(root, db_path) -> None:
def _write_tasks_config(root, base_url: str) -> None:
"""A minimal `.wikitool-tasks.json` pointing the superproductivity """A minimal `.wikitool-tasks.json` pointing the superproductivity
provider at `db_path` - same shape `test_review.py`'s own helper writes, provider's `access: "api"` path at `base_url` (Gitea #133) - `new
duplicated locally rather than imported since test files in this suite project` only ever has a write path on that access mode. Duplicated
do not import each other (see `instructions/dev/testing-conventions.md` locally rather than imported since test files in this suite do not
for the isolation this mirrors one layer up).""" import each other (see `instructions/dev/testing-conventions.md` for the
isolation this mirrors one layer up)."""
(root / ".wikitool-tasks.json").write_text( (root / ".wikitool-tasks.json").write_text(
json.dumps({ json.dumps({
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS, "schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
"superproductivity": {"db_path": str(db_path)}, "superproductivity": {"access": "api", "api_base_url": base_url, "api_token": _API_TOKEN},
}), }),
encoding="utf-8", encoding="utf-8",
) )
def _write_sp_snapshot(root, project_titles: list[str]): def _write_tasks_config_snapshot(root, backups_dir) -> None:
"""A Super Productivity backup snapshot whose `project` entity state """The other access path (Gitea #133) - used only by the tests below
holds one project per title in `project_titles` and nothing else - that confirm `new project` refuses entirely against it."""
enough for `find_project`/`create_project`'s own preflight, which is all (root / ".wikitool-tasks.json").write_text(
`new project`'s tracker step reads or writes (it never calls json.dumps({
`open_items`/`someday_items`).""" "schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
db_path = root / "db.json" "superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
}),
encoding="utf-8",
)
def _write_sp_backup(root, project_titles: list[str]) -> Path:
"""A Super Productivity backup snapshot, named the way `electron/backup.ts`
actually names it (`YYYY-MM-DD_HHmmss.json`, Gitea #133), whose `project`
entity state holds one project per title in `project_titles` and nothing
else. Used only by the `access: "snapshot"` refusal tests - that path
never reaches a tracker write regardless of what this file contains."""
backups_dir = root / "backups"
backups_dir.mkdir(exist_ok=True)
projects = { projects = {
f"p{i}": {"id": f"p{i}", "title": title, "created": 1700000000000} f"p{i}": {"id": f"p{i}", "title": title, "created": 1700000000000}
for i, title in enumerate(project_titles) for i, title in enumerate(project_titles)
} }
db_path.write_text( (backups_dir / "2026-01-01_000000.json").write_text(
json.dumps({ json.dumps({
"project": {"ids": list(projects), "entities": projects}, "project": {"ids": list(projects), "entities": projects},
"task": {"ids": [], "entities": {}}, "task": {"ids": [], "entities": {}},
@@ -47,7 +67,69 @@ def _write_sp_snapshot(root, project_titles: list[str]):
}), }),
encoding="utf-8", encoding="utf-8",
) )
return db_path return backups_dir
def _make_api_handler(state: dict):
class Handler(http.server.BaseHTTPRequestHandler):
posted = False
def do_GET(self): # noqa: N802 - stdlib method name
if self.path == "/health":
self._reply(200, {"ok": True})
return
if self.headers.get("Authorization") != f"Bearer {_API_TOKEN}":
self._reply(401, {"error": "unauthorized"})
return
if self.path == "/projects":
self._reply(200, state["projects"])
return
if self.path in ("/tasks", "/tags"):
self._reply(200, [])
return
self._reply(404, {"error": "not found"})
def do_POST(self): # noqa: N802
# `new project` must never attempt this on the API path either -
# there is no POST /projects endpoint upstream (Gitea #124/#133).
Handler.posted = True
self._reply(404, {"error": "no such endpoint (fixture)"})
def _reply(self, code: int, payload) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # silence stderr noise during the test run
pass
return Handler
@contextlib.contextmanager
def _api_server(project_titles: list[str]):
"""A stub Super Productivity local REST API serving just enough of
`GET /projects`/`/tasks`/`/tags` for `find_project`/`create_project`'s
own preflight - the only thing `new project`'s tracker step reads."""
state = {"projects": [
{"id": f"p{i}", "title": title, "created": 1700000000000, "taskIds": [], "backlogTaskIds": []}
for i, title in enumerate(project_titles)
]}
handler_cls = _make_api_handler(state)
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server, handler_cls
finally:
server.shutdown()
thread.join(timeout=2)
def _base_url(server: http.server.HTTPServer) -> str:
return f"http://127.0.0.1:{server.server_address[1]}"
def _invoke_new(monkeypatch, kb_dir, args): def _invoke_new(monkeypatch, kb_dir, args):
@@ -174,10 +256,11 @@ def test_new_project_says_explicitly_when_no_tracker_is_configured(monkeypatch,
def test_new_project_needs_clearance_when_the_tracker_has_no_write_path(monkeypatch, kb_dir): def test_new_project_needs_clearance_when_the_tracker_has_no_write_path(monkeypatch, kb_dir):
"""Super Productivity can never create a project itself (Gitea #124) - """Super Productivity can never create a project itself (Gitea #124) -
the first attempt against a free name must exit 42 and create nothing on the first attempt against a free name must exit 42 and create nothing on
either side.""" either side, and never attempt a POST (Gitea #133: no such endpoint
exists on the API path either)."""
root = kb_dir.parent root = kb_dir.parent
db_path = _write_sp_snapshot(root, []) with _api_server([]) as (server, handler_cls):
_write_tasks_config(root, db_path) _write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [ result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
@@ -186,14 +269,15 @@ def test_new_project_needs_clearance_when_the_tracker_has_no_write_path(monkeypa
assert "NEEDS USER CLEARANCE" in result.output assert "NEEDS USER CLEARANCE" in result.output
assert "--resume" in result.output assert "--resume" in result.output
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists() assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
assert handler_cls.posted is False
def test_new_project_refuses_a_tracker_collision_without_resume(monkeypatch, kb_dir): def test_new_project_refuses_a_tracker_collision_without_resume(monkeypatch, kb_dir):
"""The tracker already has this name (case-insensitively) and --resume """The tracker already has this name (case-insensitively) and --resume
was not passed - #126's AC: refuse, name the collision, create nothing.""" was not passed - #126's AC: refuse, name the collision, create nothing."""
root = kb_dir.parent root = kb_dir.parent
db_path = _write_sp_snapshot(root, ["kueche renovieren"]) with _api_server(["kueche renovieren"]) as (server, _handler_cls):
_write_tasks_config(root, db_path) _write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [ result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
@@ -209,8 +293,8 @@ def test_new_project_resume_continues_past_an_existing_tracker_project(monkeypat
re-run with --resume must verify it via the read path and continue to re-run with --resume must verify it via the read path and continue to
page creation instead of treating it as a collision.""" page creation instead of treating it as a collision."""
root = kb_dir.parent root = kb_dir.parent
db_path = _write_sp_snapshot(root, ["Kueche renovieren"]) with _api_server(["Kueche renovieren"]) as (server, _handler_cls):
_write_tasks_config(root, db_path) _write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [ result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume", "new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
@@ -226,8 +310,8 @@ def test_new_project_resume_still_refuses_when_the_human_has_not_acted_yet(monke
message again, not a silent pass-through (#126's own wording: "wirft das message again, not a silent pass-through (#126's own wording: "wirft das
Kommando dieselbe HumanInterventionRequired-Meldung erneut").""" Kommando dieselbe HumanInterventionRequired-Meldung erneut")."""
root = kb_dir.parent root = kb_dir.parent
db_path = _write_sp_snapshot(root, []) with _api_server([]) as (server, _handler_cls):
_write_tasks_config(root, db_path) _write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [ result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume", "new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
@@ -236,6 +320,38 @@ def test_new_project_resume_still_refuses_when_the_human_has_not_acted_yet(monke
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists() assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_new_project_refuses_entirely_on_snapshot_access(monkeypatch, kb_dir):
"""Gitea #133: `access: "snapshot"` is read-only from here - `new
project` creates neither a tracker project nor a page and exits 1, not
42 (nothing is waiting on a human's clearance, the command simply cannot
do this from a snapshot instance), pointing at an access: "api" one."""
root = kb_dir.parent
backups_dir = _write_sp_backup(root, [])
_write_tasks_config_snapshot(root, backups_dir)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
])
assert result.exit_code == 1
assert "access: 'api'" in result.output
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_new_project_resume_also_refuses_entirely_on_snapshot_access(monkeypatch, kb_dir):
"""--resume changes nothing about the snapshot-access refusal (Gitea
#133's own AC) - even against a tracker that already has the project."""
root = kb_dir.parent
backups_dir = _write_sp_backup(root, ["Kueche renovieren"])
_write_tasks_config_snapshot(root, backups_dir)
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
])
assert result.exit_code == 1
assert "access: 'api'" in result.output
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
def test_resume_flag_is_refused_for_any_type_other_than_project(monkeypatch, kb_dir): def test_resume_flag_is_refused_for_any_type_other_than_project(monkeypatch, kb_dir):
result = _invoke_new(monkeypatch, kb_dir, [ result = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", "Irrelevant", "--set", "entity_type=tool", "--resume", "new", "entity", "--name", "Irrelevant", "--set", "entity_type=tool", "--resume",
@@ -253,14 +369,15 @@ def test_new_project_never_leaves_only_the_page_when_the_write_fails(monkeypatch
import chemenu.commands.new_page as new_page import chemenu.commands.new_page as new_page
root = kb_dir.parent root = kb_dir.parent
db_path = _write_sp_snapshot(root, ["Kueche renovieren"])
_write_tasks_config(root, db_path)
def _boom(path, frontmatter, body): def _boom(path, frontmatter, body):
raise OSError("disk full (fixture)") raise OSError("disk full (fixture)")
monkeypatch.setattr(new_page, "write_page", _boom) monkeypatch.setattr(new_page, "write_page", _boom)
with _api_server(["Kueche renovieren"]) as (server, _handler_cls):
_write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [ result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume", "new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
]) ])
+83 -27
View File
@@ -51,8 +51,14 @@ def _entity_state(records: dict[str, dict]) -> dict:
def _write_snapshot(root: Path, projects: dict, tasks: dict, tags: dict) -> Path: def _write_snapshot(root: Path, projects: dict, tasks: dict, tags: dict) -> Path:
db_path = root / "db.json" """Writes a Super Productivity backup snapshot into a fresh `backups/`
db_path.write_text( directory under `root`, named the way `electron/backup.ts` actually
names it (`YYYY-MM-DD_HHmmss.json`, Gitea #133) so the hardened glob in
`latest_snapshot_path` picks it up. Returns the `backups_dir`, not the
file itself - that is what a `superproductivity` config section names."""
backups_dir = root / "backups"
backups_dir.mkdir(exist_ok=True)
(backups_dir / "2026-01-01_000000.json").write_text(
json.dumps({ json.dumps({
"project": _entity_state(projects), "project": _entity_state(projects),
"task": _entity_state(tasks), "task": _entity_state(tasks),
@@ -60,16 +66,16 @@ def _write_snapshot(root: Path, projects: dict, tasks: dict, tags: dict) -> Path
}), }),
encoding="utf-8", encoding="utf-8",
) )
return db_path return backups_dir
def _write_tasks_config(root: Path, db_path: Path, thresholds: dict | None = None) -> None: def _write_tasks_config(root: Path, backups_dir: Path, thresholds: dict | None = None) -> None:
(root / ".wikitool-tasks.json").write_text( (root / ".wikitool-tasks.json").write_text(
json.dumps({ json.dumps({
"schema": 1, "schema": 1,
"provider": "superproductivity", "provider": "superproductivity",
"thresholds": thresholds or THRESHOLDS, "thresholds": thresholds or THRESHOLDS,
"superproductivity": {"db_path": str(db_path)}, "superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
}), }),
encoding="utf-8", encoding="utf-8",
) )
@@ -110,8 +116,8 @@ def _tree(root: Path) -> dict[str, bytes]:
def test_check1_stalled_only_fires_for_active(tmp_path, state, should_fire): def test_check1_stalled_only_fires_for_active(tmp_path, state, should_fire):
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1), projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}} "taskIds": [], "backlogTaskIds": []}}
db_path = _write_snapshot(tmp_path, projects, {}, {}) backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, db_path) _write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ship Chemenu 7.0", state) _project_page(tmp_path, "Ship Chemenu 7.0", state)
report = run_review(tmp_path, today=TODAY) report = run_review(tmp_path, today=TODAY)
@@ -131,10 +137,10 @@ def test_check2_waiting_overdue_threshold(tmp_path, remind_day, should_fire):
projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1), projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}} "taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot - Tobias", "isDone": False, tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot - Tobias", "isDone": False,
"tagIds": ["tag-wait"], "remindAt": _ms(2026, month, remind_day)}} "tagIds": ["tag-wait"], "dueWithTime": _ms(2026, month, remind_day)}}
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}} tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
db_path = _write_snapshot(tmp_path, projects, tasks, tags) backups_dir = _write_snapshot(tmp_path, projects, tasks, tags)
_write_tasks_config(tmp_path, db_path) _write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Kueche renovieren", "active") _project_page(tmp_path, "Kueche renovieren", "active")
report = run_review(tmp_path, today=TODAY) report = run_review(tmp_path, today=TODAY)
@@ -142,6 +148,23 @@ def test_check2_waiting_overdue_threshold(tmp_path, remind_day, should_fire):
assert fired == should_fire assert fired == should_fire
def test_check2_fires_for_an_all_day_waiting_item_with_no_due_with_time(tmp_path):
"""The gap Gitea #135 closed: a `waiting` task scheduled all-day
(`dueDay`, no `dueWithTime`, no reminder) must still surface as overdue -
under #124's original `remindAt` mapping it silently never did."""
projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot - Tobias", "isDone": False,
"tagIds": ["tag-wait"], "dueDay": "2026-01-01"}}
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
backups_dir = _write_snapshot(tmp_path, projects, tasks, tags)
_write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Kueche renovieren", "active")
report = run_review(tmp_path, today=TODAY)
assert any(f.check == CHECK_WAITING_OVERDUE for f in report.findings)
# --- check 3: unpaged tracker project ---------------------------------------- # --- check 3: unpaged tracker project ----------------------------------------
@@ -153,8 +176,8 @@ def test_check3_unpaged_project_age_threshold(tmp_path, created_year_month_day,
y, m, d = created_year_month_day y, m, d = created_year_month_day
projects = {"p1": {"id": "p1", "title": "No Page Yet", "created": _ms(y, m, d), projects = {"p1": {"id": "p1", "title": "No Page Yet", "created": _ms(y, m, d),
"taskIds": [], "backlogTaskIds": []}} "taskIds": [], "backlogTaskIds": []}}
db_path = _write_snapshot(tmp_path, projects, {}, {}) backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, db_path) _write_tasks_config(tmp_path, backups_dir)
(tmp_path / "kb" / "gtd").mkdir(parents=True) (tmp_path / "kb" / "gtd").mkdir(parents=True)
report = run_review(tmp_path, today=TODAY) report = run_review(tmp_path, today=TODAY)
@@ -168,8 +191,8 @@ def test_check3_and_check2_are_case_normalized_and_report_no_mismatch(tmp_path):
projects = {"p1": {"id": "p1", "title": "kueche renovieren", "created": _ms(2020, 1, 1), projects = {"p1": {"id": "p1", "title": "kueche renovieren", "created": _ms(2020, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}} "taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Irgendwas tun", "isDone": False, "tagIds": []}} tasks = {"t1": {"id": "t1", "title": "Irgendwas tun", "isDone": False, "tagIds": []}}
db_path = _write_snapshot(tmp_path, projects, tasks, {}) backups_dir = _write_snapshot(tmp_path, projects, tasks, {})
_write_tasks_config(tmp_path, db_path) _write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Kueche renovieren", "active") _project_page(tmp_path, "Kueche renovieren", "active")
report = run_review(tmp_path, today=TODAY) report = run_review(tmp_path, today=TODAY)
@@ -181,8 +204,8 @@ def test_check3_and_check2_are_case_normalized_and_report_no_mismatch(tmp_path):
def test_check4_fires_when_no_tracker_project_exists(tmp_path): def test_check4_fires_when_no_tracker_project_exists(tmp_path):
db_path = _write_snapshot(tmp_path, {}, {}, {}) backups_dir = _write_snapshot(tmp_path, {}, {}, {})
_write_tasks_config(tmp_path, db_path) _write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ghost Project", "active") _project_page(tmp_path, "Ghost Project", "active")
report = run_review(tmp_path, today=TODAY) report = run_review(tmp_path, today=TODAY)
@@ -199,8 +222,8 @@ def test_a_run_touches_no_file(tmp_path):
"taskIds": [], "backlogTaskIds": ["t3"]}} "taskIds": [], "backlogTaskIds": ["t3"]}}
tasks = {"t3": {"id": "t3", "title": "Someday item", "isDone": False, "tagIds": [], tasks = {"t3": {"id": "t3", "title": "Someday item", "isDone": False, "tagIds": [],
"updated": _ms(2020, 1, 1)}} "updated": _ms(2020, 1, 1)}}
db_path = _write_snapshot(tmp_path, projects, tasks, {}) backups_dir = _write_snapshot(tmp_path, projects, tasks, {})
_write_tasks_config(tmp_path, db_path) _write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ship Chemenu 7.0", "active") _project_page(tmp_path, "Ship Chemenu 7.0", "active")
before = _tree(tmp_path) before = _tree(tmp_path)
@@ -221,8 +244,8 @@ def test_check5_someday_stale_threshold(tmp_path, updated_ymd, should_fire):
"taskIds": [], "backlogTaskIds": ["t3"]}} "taskIds": [], "backlogTaskIds": ["t3"]}}
tasks = {"t3": {"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False, tasks = {"t3": {"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False,
"tagIds": [], "updated": _ms(y, m, 1)}} "tagIds": [], "updated": _ms(y, m, 1)}}
db_path = _write_snapshot(tmp_path, projects, tasks, {}) backups_dir = _write_snapshot(tmp_path, projects, tasks, {})
_write_tasks_config(tmp_path, db_path) _write_tasks_config(tmp_path, backups_dir)
report = run_review(tmp_path, today=TODAY) report = run_review(tmp_path, today=TODAY)
fired = any(f.check == CHECK_SOMEDAY_STALE for f in report.findings) fired = any(f.check == CHECK_SOMEDAY_STALE for f in report.findings)
@@ -239,10 +262,10 @@ def test_no_tasks_config_is_a_clear_validation_error(tmp_path):
def test_unreachable_provider_skips_the_project_list_checks_but_not_someday(tmp_path): def test_unreachable_provider_skips_the_project_list_checks_but_not_someday(tmp_path):
"""A db_path that does not exist is the unreachable-provider case: checks """A backups_dir that does not exist is the unreachable-provider case: checks
1/2/3/4 cannot run at all, but check 5 uses the same failing read and is 1/2/3/4 cannot run at all, but check 5 uses the same failing read and is
skipped too - the report must say so, never look like a quiet week.""" skipped too - the report must say so, never look like a quiet week."""
_write_tasks_config(tmp_path, tmp_path / "does-not-exist.json") _write_tasks_config(tmp_path, tmp_path / "does-not-exist-dir")
_project_page(tmp_path, "Ship Chemenu 7.0", "active") _project_page(tmp_path, "Ship Chemenu 7.0", "active")
report = run_review(tmp_path, today=TODAY) report = run_review(tmp_path, today=TODAY)
@@ -275,7 +298,7 @@ def test_cli_incomplete_report_exits_1_and_still_prints(tmp_path, monkeypatch):
monkeypatch.setattr(config, "ROOT", tmp_path) monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch) use_shipped_type_specs(monkeypatch)
_write_tasks_config(tmp_path, tmp_path / "does-not-exist.json") _write_tasks_config(tmp_path, tmp_path / "does-not-exist-dir")
(tmp_path / "kb" / "gtd").mkdir(parents=True) (tmp_path / "kb" / "gtd").mkdir(parents=True)
result = runner.invoke(app, ["review"]) result = runner.invoke(app, ["review"])
@@ -294,8 +317,8 @@ def test_cli_json_and_text_agree_on_findings(tmp_path, monkeypatch):
use_shipped_type_specs(monkeypatch) use_shipped_type_specs(monkeypatch)
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1), projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}} "taskIds": [], "backlogTaskIds": []}}
db_path = _write_snapshot(tmp_path, projects, {}, {}) backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, db_path) _write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ship Chemenu 7.0", "active") _project_page(tmp_path, "Ship Chemenu 7.0", "active")
text_result = runner.invoke(app, ["review"]) text_result = runner.invoke(app, ["review"])
@@ -321,6 +344,39 @@ def test_cli_json_and_text_agree_on_findings(tmp_path, monkeypatch):
} }
def test_report_names_its_source_and_the_snapshot_age(tmp_path):
"""Gitea #133's own AC: every answer says which access path it came from,
and a snapshot answer says how old it is."""
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}}
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
report = run_review(tmp_path, today=TODAY)
assert report.source is not None
assert report.source.kind == "snapshot"
assert "day(s) old" in report.source.detail
def test_cli_shows_the_source_in_both_render_forms(tmp_path, monkeypatch):
from chemenu import config
monkeypatch.setattr(config, "ROOT", tmp_path)
use_shipped_type_specs(monkeypatch)
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}}
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, backups_dir)
text_result = runner.invoke(app, ["review"])
json_result = runner.invoke(app, ["review", "--json"])
assert "Source: snapshot" in text_result.output
payload = json.loads(json_result.output)
assert payload["source"]["kind"] == "snapshot"
assert "day(s) old" in payload["source"]["detail"]
def test_review_is_exempt_from_the_iteration_budget(): def test_review_is_exempt_from_the_iteration_budget():
assert is_exempt("review", []) is True assert is_exempt("review", []) is True
assert is_exempt("review", ["--json"]) is True assert is_exempt("review", ["--json"]) is True
@@ -339,8 +395,8 @@ def test_a_run_leaves_the_git_tree_untouched(tmp_path, monkeypatch):
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1), projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": []}} "taskIds": [], "backlogTaskIds": []}}
db_path = _write_snapshot(tmp_path, projects, {}, {}) backups_dir = _write_snapshot(tmp_path, projects, {}, {})
_write_tasks_config(tmp_path, db_path) _write_tasks_config(tmp_path, backups_dir)
_project_page(tmp_path, "Ship Chemenu 7.0", "active") _project_page(tmp_path, "Ship Chemenu 7.0", "active")
subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True)
subprocess.run(["git", "commit", "-q", "-m", "fixture"], cwd=tmp_path, check=True) subprocess.run(["git", "commit", "-q", "-m", "fixture"], cwd=tmp_path, check=True)
+344 -46
View File
@@ -1,21 +1,23 @@
"""Tests for `chemenu.tasks.superproductivity` (Gitea #124). """Tests for `chemenu.tasks.superproductivity` (Gitea #124, #133, #135).
The fixture snapshot below mirrors the real shape verified against The fixture snapshot below mirrors the real shape verified against
`super-productivity/super-productivity`'s `master` branch on 2026-09-19: a `super-productivity/super-productivity`'s `master` branch: a flat top-level
flat top-level object with `project`/`task`/`tag` as `@ngrx/entity` object with `project`/`task`/`tag` as `@ngrx/entity` `{"ids": [...],
`{"ids": [...], "entities": {...}}` maps (see the module docstring for the "entities": {...}}` maps for the snapshot path, and the same records as a
exact source files). No test here starts a real Super Productivity instance, flat list (the local REST API's own shape) for the API path (see the module
touches the network beyond a loopback socket, or reads a path under the docstring for the exact source files). No test here starts a real Super
developer's home - everything is built inside `tmp_path` Productivity instance or touches anything beyond a loopback socket and
(`instructions/dev/testing-conventions.md`). `tmp_path` (`instructions/dev/testing-conventions.md`).
""" """
from __future__ import annotations from __future__ import annotations
import contextlib
import http.server import http.server
import json import json
import threading import threading
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any
import pytest import pytest
@@ -55,7 +57,7 @@ def _snapshot() -> dict:
"projectId": "p1", "projectId": "p1",
"isDone": False, "isDone": False,
"tagIds": ["tag-wait"], "tagIds": ["tag-wait"],
"remindAt": _ms(2026, 3, 1), "dueWithTime": _ms(2026, 3, 1),
}, },
"t2": { "t2": {
"id": "t2", "id": "t2",
@@ -94,73 +96,115 @@ def _write_snapshot(path: Path, data: dict | None = None) -> None:
path.write_text(json.dumps(data if data is not None else _snapshot()), encoding="utf-8") path.write_text(json.dumps(data if data is not None else _snapshot()), encoding="utf-8")
def _minimal_snapshot(task_extra: dict) -> dict:
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": ["t1"], "backlogTaskIds": []}}
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot", "isDone": False,
"tagIds": ["tag-wait"], **task_extra}}
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
return {"project": _entity_state(projects), "task": _entity_state(tasks), "tag": _entity_state(tags)}
@pytest.fixture @pytest.fixture
def cfg(tmp_path) -> sp.SuperProductivityConfig: def cfg(tmp_path) -> sp.SuperProductivityConfig:
db_path = tmp_path / "db.json" backups_dir = tmp_path / "backups"
_write_snapshot(db_path) backups_dir.mkdir()
_write_snapshot(backups_dir / "2026-03-01_120000.json")
return sp.SuperProductivityConfig( return sp.SuperProductivityConfig(
backups_dir=None, db_path=db_path, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None access=sp.ACCESS_SNAPSHOT, backups_dir=backups_dir,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
) )
# --- from_dict --------------------------------------------------------------- # --- from_dict -----------------------------------------------------------------
def test_from_dict_requires_backups_dir_or_db_path(): def test_from_dict_requires_access():
with pytest.raises(ValidationError): with pytest.raises(ValidationError, match="access"):
sp.SuperProductivityConfig.from_dict({"api_base_url": "http://127.0.0.1:3876"}) sp.SuperProductivityConfig.from_dict({"backups_dir": "/x"})
def test_from_dict_defaults_the_api_base_url(): def test_from_dict_rejects_an_unknown_access_value():
cfg_ = sp.SuperProductivityConfig.from_dict({"db_path": "/x/db.json"}) with pytest.raises(ValidationError, match="access"):
sp.SuperProductivityConfig.from_dict({"access": "auto", "backups_dir": "/x"})
def test_from_dict_api_requires_a_token():
with pytest.raises(ValidationError, match="api_token"):
sp.SuperProductivityConfig.from_dict({"access": "api"})
def test_from_dict_api_defaults_the_base_url():
cfg_ = sp.SuperProductivityConfig.from_dict({"access": "api", "api_token": "t"})
assert cfg_.api_base_url == sp.DEFAULT_API_BASE_URL assert cfg_.api_base_url == sp.DEFAULT_API_BASE_URL
assert cfg_.access == sp.ACCESS_API
def test_from_dict_expands_user_in_paths(monkeypatch): def test_from_dict_api_rejects_a_snapshot_field():
with pytest.raises(ValidationError, match="backups_dir"):
sp.SuperProductivityConfig.from_dict(
{"access": "api", "api_token": "t", "backups_dir": "/x"}
)
def test_from_dict_snapshot_requires_backups_dir():
with pytest.raises(ValidationError, match="backups_dir"):
sp.SuperProductivityConfig.from_dict({"access": "snapshot"})
def test_from_dict_snapshot_rejects_an_api_field():
with pytest.raises(ValidationError, match="api_token"):
sp.SuperProductivityConfig.from_dict(
{"access": "snapshot", "backups_dir": "/x", "api_token": "t"}
)
def test_from_dict_snapshot_expands_user_in_backups_dir(monkeypatch):
monkeypatch.setenv("HOME", "/home/fixture") monkeypatch.setenv("HOME", "/home/fixture")
cfg_ = sp.SuperProductivityConfig.from_dict({"backups_dir": "~/backups"}) cfg_ = sp.SuperProductivityConfig.from_dict({"access": "snapshot", "backups_dir": "~/backups"})
assert cfg_.backups_dir == Path("/home/fixture/backups") assert cfg_.backups_dir == Path("/home/fixture/backups")
# --- latest_snapshot_path / schema drift ------------------------------------- def test_from_dict_rejects_db_path_entirely():
"""`db_path` was #133's own casualty - it must not silently work as an
def test_db_path_must_exist(tmp_path): alias for `backups_dir` under either access mode."""
cfg_ = sp.SuperProductivityConfig(
backups_dir=None, db_path=tmp_path / "missing.json",
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
)
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
sp.latest_snapshot_path(cfg_) sp.SuperProductivityConfig.from_dict({"access": "snapshot", "db_path": "/x/db.json"})
# --- latest_snapshot_path / schema drift ---------------------------------------
def test_backups_dir_must_exist(tmp_path): def test_backups_dir_must_exist(tmp_path):
cfg_ = sp.SuperProductivityConfig( cfg_ = sp.SuperProductivityConfig(
backups_dir=tmp_path / "nope", db_path=None, access=sp.ACCESS_SNAPSHOT, backups_dir=tmp_path / "nope",
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
) )
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
sp.latest_snapshot_path(cfg_) sp.latest_snapshot_path(cfg_)
def test_backups_dir_with_no_json_files_is_an_error(tmp_path): def test_backups_dir_with_no_timestamped_file_is_an_error(tmp_path):
backups = tmp_path / "backups" backups = tmp_path / "backups"
backups.mkdir() backups.mkdir()
(backups / "sp-backup_2026-01-01.json").write_text("{}", encoding="utf-8")
cfg_ = sp.SuperProductivityConfig( cfg_ = sp.SuperProductivityConfig(
backups_dir=backups, db_path=None, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None, access=sp.ACCESS_SNAPSHOT, backups_dir=backups,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
) )
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
sp.latest_snapshot_path(cfg_) sp.latest_snapshot_path(cfg_)
def test_backups_dir_picks_the_lexically_latest_file(tmp_path): def test_backups_dir_picks_the_lexically_latest_timestamped_file(tmp_path):
backups = tmp_path / "backups" backups = tmp_path / "backups"
backups.mkdir() backups.mkdir()
older = _snapshot() older = _snapshot()
older["project"]["entities"]["p1"]["title"] = "Old Snapshot Project" older["project"]["entities"]["p1"]["title"] = "Old Snapshot Project"
newer = _snapshot() newer = _snapshot()
_write_snapshot(backups / "2026-01-01T000000Z.json", older) _write_snapshot(backups / "2026-01-01_000000.json", older)
_write_snapshot(backups / "2026-02-01T000000Z.json", newer) _write_snapshot(backups / "2026-02-01_000000.json", newer)
cfg_ = sp.SuperProductivityConfig( cfg_ = sp.SuperProductivityConfig(
backups_dir=backups, db_path=None, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None, access=sp.ACCESS_SNAPSHOT, backups_dir=backups,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
) )
reader = sp.SuperProductivityReader(cfg_) reader = sp.SuperProductivityReader(cfg_)
names = {p.name for p in reader.projects()} names = {p.name for p in reader.projects()}
@@ -168,10 +212,31 @@ def test_backups_dir_picks_the_lexically_latest_file(tmp_path):
assert "Old Snapshot Project" not in names assert "Old Snapshot Project" not in names
def test_a_manual_export_never_wins_even_placed_next_to_an_older_timestamp(tmp_path):
"""`sp-backup_*.json` sorts lexically *after* every timestamp - the
hardened glob (Gitea #133) must never pick it, whatever its own name or
mtime looks like next to the real backups."""
backups = tmp_path / "backups"
backups.mkdir()
timestamped = _snapshot()
manual_export = _snapshot()
manual_export["project"]["entities"]["p1"]["title"] = "From Manual Export"
_write_snapshot(backups / "2026-01-01_000000.json", timestamped)
_write_snapshot(backups / "sp-backup_2026-06-01.json", manual_export)
cfg_ = sp.SuperProductivityConfig(
access=sp.ACCESS_SNAPSHOT, backups_dir=backups,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
)
reader = sp.SuperProductivityReader(cfg_)
names = {p.name for p in reader.projects()}
assert "Ship Chemenu 7.0" in names
assert "From Manual Export" not in names
def test_schema_drift_on_task_key_fails_loud_not_silent(cfg): def test_schema_drift_on_task_key_fails_loud_not_silent(cfg):
data = _snapshot() data = _snapshot()
data["task"] = ["not", "an", "entity", "state"] data["task"] = ["not", "an", "entity", "state"]
_write_snapshot(cfg.db_path, data) _write_snapshot(sp.latest_snapshot_path(cfg), data)
reader = sp.SuperProductivityReader(cfg) reader = sp.SuperProductivityReader(cfg)
with pytest.raises(ValidationError, match="task"): with pytest.raises(ValidationError, match="task"):
reader.projects() reader.projects()
@@ -180,13 +245,13 @@ def test_schema_drift_on_task_key_fails_loud_not_silent(cfg):
def test_missing_top_level_key_fails_loud(cfg): def test_missing_top_level_key_fails_loud(cfg):
data = _snapshot() data = _snapshot()
del data["project"] del data["project"]
_write_snapshot(cfg.db_path, data) _write_snapshot(sp.latest_snapshot_path(cfg), data)
reader = sp.SuperProductivityReader(cfg) reader = sp.SuperProductivityReader(cfg)
with pytest.raises(ValidationError, match="project"): with pytest.raises(ValidationError, match="project"):
reader.projects() reader.projects()
# --- read path ---------------------------------------------------------------- # --- read path (snapshot) -------------------------------------------------------
def test_projects_lists_name_and_created(cfg): def test_projects_lists_name_and_created(cfg):
reader = sp.SuperProductivityReader(cfg) reader = sp.SuperProductivityReader(cfg)
@@ -195,13 +260,23 @@ def test_projects_lists_name_and_created(cfg):
assert by_name["Kueche renovieren"].created == date(2026, 2, 1) assert by_name["Kueche renovieren"].created == date(2026, 2, 1)
def test_projects_excludes_archived(cfg):
data = _snapshot()
data["project"]["entities"]["p1"]["isArchived"] = True
_write_snapshot(sp.latest_snapshot_path(cfg), data)
reader = sp.SuperProductivityReader(cfg)
names = {p.name for p in reader.projects()}
assert "Ship Chemenu 7.0" not in names
assert "Kueche renovieren" in names
def test_open_items_counts_undone_tasks_and_matches_name_case_insensitively(cfg): def test_open_items_counts_undone_tasks_and_matches_name_case_insensitively(cfg):
reader = sp.SuperProductivityReader(cfg) reader = sp.SuperProductivityReader(cfg)
result = reader.open_items("ship CHEMENU 7.0") result = reader.open_items("ship CHEMENU 7.0")
assert result.count == 2 assert result.count == 2
def test_open_items_reports_waiting_with_follow_up_at_not_due_date(cfg): def test_open_items_reports_waiting_with_follow_up_at_from_due_with_time(cfg):
reader = sp.SuperProductivityReader(cfg) reader = sp.SuperProductivityReader(cfg)
result = reader.open_items("Ship Chemenu 7.0") result = reader.open_items("Ship Chemenu 7.0")
assert len(result.waiting) == 1 assert len(result.waiting) == 1
@@ -230,7 +305,49 @@ def test_someday_items_come_from_backlog_task_ids_only(cfg):
assert items[0].modified == date(2026, 1, 15) assert items[0].modified == date(2026, 1, 15)
# --- write path: HumanInterventionRequired ----------------------------------- def test_source_names_the_snapshot_file(cfg):
source = sp.SuperProductivityReader(cfg).source()
assert source.kind == sp.ACCESS_SNAPSHOT
assert "2026-03-01_120000.json" in source.detail
# --- follow_up_at mapping (Gitea #135) -------------------------------------------
def test_follow_up_at_reads_due_with_time(cfg):
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({"dueWithTime": _ms(2026, 3, 1)}))
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
assert waiting[0].follow_up_at == date(2026, 3, 1)
def test_follow_up_at_falls_back_to_due_day(cfg):
"""The gap #135 closed: an all-day, notification-free tickler has no
`dueWithTime` and no (now-removed) `remindAt` at all, and must still be
read as a follow-up date."""
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({"dueDay": "2026-03-15"}))
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
assert waiting[0].follow_up_at == date(2026, 3, 15)
def test_follow_up_at_prefers_due_with_time_over_due_day(cfg):
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({
"dueWithTime": _ms(2026, 3, 1), "dueDay": "2026-04-01",
}))
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
assert waiting[0].follow_up_at == date(2026, 3, 1)
def test_follow_up_at_never_reads_remind_at_or_deadline_fields(cfg):
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({
"remindAt": _ms(2026, 3, 1),
"deadlineDay": "2026-03-01",
"deadlineWithTime": _ms(2026, 3, 1),
"deadlineRemindAt": _ms(2026, 3, 1),
}))
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
assert waiting[0].follow_up_at is None
# --- write path: HumanInterventionRequired ---------------------------------------
def test_create_project_refuses_a_name_collision(cfg): def test_create_project_refuses_a_name_collision(cfg):
reader = sp.SuperProductivityReader(cfg) reader = sp.SuperProductivityReader(cfg)
@@ -257,17 +374,17 @@ def test_create_project_asks_a_human_and_verify_reflects_the_read_path(cfg):
"id": "p3", "title": "Kueche renovieren, Phase 2", "created": _ms(2026, 4, 1), "id": "p3", "title": "Kueche renovieren, Phase 2", "created": _ms(2026, 4, 1),
"taskIds": [], "backlogTaskIds": [], "taskIds": [], "backlogTaskIds": [],
} }
_write_snapshot(cfg.db_path, data) _write_snapshot(sp.latest_snapshot_path(cfg), data)
assert exc.verify() is True assert exc.verify() is True
# --- health ------------------------------------------------------------------- # --- health ------------------------------------------------------------------------
def test_health_is_false_when_nothing_listens(tmp_path): def test_health_is_false_when_nothing_listens(tmp_path):
cfg_ = sp.SuperProductivityConfig( cfg_ = sp.SuperProductivityConfig(
backups_dir=None, db_path=tmp_path / "db.json", access=sp.ACCESS_API, backups_dir=None,
api_base_url="http://127.0.0.1:1", api_token=None, api_base_url="http://127.0.0.1:1", api_token="t",
) )
assert sp.health(cfg_, timeout=0.5) is False assert sp.health(cfg_, timeout=0.5) is False
@@ -289,10 +406,191 @@ def test_health_is_true_when_the_endpoint_answers(tmp_path):
try: try:
port = server.server_address[1] port = server.server_address[1]
cfg_ = sp.SuperProductivityConfig( cfg_ = sp.SuperProductivityConfig(
backups_dir=None, db_path=tmp_path / "db.json", access=sp.ACCESS_API, backups_dir=None,
api_base_url=f"http://127.0.0.1:{port}", api_token=None, api_base_url=f"http://127.0.0.1:{port}", api_token="t",
) )
assert sp.health(cfg_, timeout=2.0) is True assert sp.health(cfg_, timeout=2.0) is True
finally: finally:
server.shutdown() server.shutdown()
thread.join(timeout=2) thread.join(timeout=2)
# --- the API read path (Gitea #133) -------------------------------------------------
def _make_api_handler(routes: dict[str, Any], token: str, *, status_override: dict[str, int] | None = None):
status_override = status_override or {}
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
if self.path == "/health":
self._reply(200, {"ok": True})
return
override = status_override.get(self.path)
if override is not None:
self._reply(override, {"error": "fixture"})
return
if self.headers.get("Authorization") != f"Bearer {token}":
self._reply(401, {"error": "unauthorized"})
return
if self.path in routes:
self._reply(200, routes[self.path])
return
self._reply(404, {"error": "not found"})
def _reply(self, code: int, payload: Any) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # noqa: D401 - silence stderr noise
pass
return Handler
@contextlib.contextmanager
def _api_server(routes: dict[str, Any], *, token: str = "test-token", status_override=None):
handler_cls = _make_api_handler(routes, token, status_override=status_override)
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
thread.join(timeout=2)
def _api_cfg(server: http.server.HTTPServer, *, token: str = "test-token") -> sp.SuperProductivityConfig:
port = server.server_address[1]
return sp.SuperProductivityConfig(
access=sp.ACCESS_API, backups_dir=None,
api_base_url=f"http://127.0.0.1:{port}", api_token=token,
)
def _fixture_records() -> tuple[list[dict], list[dict], list[dict]]:
projects = [
{"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
"taskIds": ["t1", "t2"], "backlogTaskIds": ["t3"]},
{"id": "p2", "title": "Archived Project", "created": _ms(2026, 1, 1),
"taskIds": [], "backlogTaskIds": [], "isArchived": True},
]
tasks = [
{"id": "t1", "title": "Warte auf Angebot vom Elektriker - Tobias", "isDone": False,
"tagIds": ["tag-wait"], "dueDay": "2026-03-15"},
# A subtask: inherits projectId from its parent, is NOT itself in
# project.taskIds (only t1/t2 are) - counting via project.taskIds
# must not pick it up, matching the snapshot path (Gitea #133).
{"id": "t2b", "title": "Unterschritt von t1", "isDone": False, "tagIds": [],
"projectId": "p1", "parentId": "t1"},
{"id": "t2", "title": "Kickoff-Meeting vorbereiten", "isDone": False, "tagIds": []},
{"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False, "tagIds": [],
"updated": _ms(2026, 1, 15)},
]
tags = [{"id": "tag-wait", "title": "Waiting"}]
return projects, tasks, tags
def test_api_reader_projects_excludes_archived():
projects, tasks, tags = _fixture_records()
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
names = {p.name for p in reader.projects()}
assert "Ship Chemenu 7.0" in names
assert "Archived Project" not in names
def test_api_reader_open_items_counts_against_project_task_ids_not_project_id_filter():
projects, tasks, tags = _fixture_records()
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
result = reader.open_items("Ship Chemenu 7.0")
assert result.count == 2 # t1, t2 - not the subtask t2b
assert len(result.waiting) == 1
assert result.waiting[0].follow_up_at == date(2026, 3, 15)
def test_api_reader_someday_items():
projects, tasks, tags = _fixture_records()
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
items = reader.someday_items()
assert [i.title for i in items] == ["Irgendwann Keller aufraeumen"]
def test_api_reader_401_without_the_right_token_fails_loud():
projects, tasks, tags = _fixture_records()
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}, token="right-token") as server:
cfg_ = _api_cfg(server, token="wrong-token")
with pytest.raises(ValidationError):
sp.SuperProductivityApiReader(cfg_).projects()
def test_api_reader_503_app_not_ready_is_its_own_message():
with _api_server({"/projects": []}, status_override={"/projects": 503}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
with pytest.raises(ValidationError, match="APP_NOT_READY"):
reader.projects()
def test_api_reader_unreachable_fails_loud_not_silent(tmp_path):
cfg_ = sp.SuperProductivityConfig(
access=sp.ACCESS_API, backups_dir=None,
api_base_url="http://127.0.0.1:1", api_token="t",
)
with pytest.raises(ValidationError):
sp.SuperProductivityApiReader(cfg_).projects()
def test_api_reader_non_list_response_fails_loud():
with _api_server({"/projects": {"not": "a list"}}) as server:
reader = sp.SuperProductivityApiReader(_api_cfg(server))
with pytest.raises(ValidationError):
reader.projects()
def test_api_reader_source_is_live_and_needs_no_network():
"""`source()` on the API path must not itself perform a request - a
static description is correct regardless of reachability (Gitea #133)."""
cfg_ = sp.SuperProductivityConfig(
access=sp.ACCESS_API, backups_dir=None,
api_base_url="http://127.0.0.1:1", api_token="t",
)
source = sp.SuperProductivityApiReader(cfg_).source()
assert source.kind == sp.ACCESS_API
# --- equivalence: both access paths agree on the same fixture (Gitea #133) --------
def test_snapshot_and_api_readers_agree_on_the_same_fixture(tmp_path):
projects, tasks, tags = _fixture_records()
backups_dir = tmp_path / "backups"
backups_dir.mkdir()
_write_snapshot(backups_dir / "2026-03-01_000000.json", {
"project": _entity_state({p["id"]: p for p in projects}),
"task": _entity_state({t["id"]: t for t in tasks}),
"tag": _entity_state({t["id"]: t for t in tags}),
})
snapshot_reader = sp.SuperProductivityReader(sp.SuperProductivityConfig(
access=sp.ACCESS_SNAPSHOT, backups_dir=backups_dir,
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
))
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
api_reader = sp.SuperProductivityApiReader(_api_cfg(server))
assert {p.name: p.created for p in snapshot_reader.projects()} == \
{p.name: p.created for p in api_reader.projects()}
snap_items = snapshot_reader.open_items("Ship Chemenu 7.0")
api_items = api_reader.open_items("Ship Chemenu 7.0")
assert snap_items.count == api_items.count
assert [(w.title, w.follow_up_at) for w in snap_items.waiting] == \
[(w.title, w.follow_up_at) for w in api_items.waiting]
assert [(s.title, s.modified) for s in snapshot_reader.someday_items()] == \
[(s.title, s.modified) for s in api_reader.someday_items()]
+1 -1
View File
@@ -19,8 +19,8 @@ VALID = {
"someday_stale_months": 5, "someday_stale_months": 5,
}, },
"superproductivity": { "superproductivity": {
"access": "snapshot",
"backups_dir": "/tmp/does-not-need-to-exist-for-parsing", "backups_dir": "/tmp/does-not-need-to-exist-for-parsing",
"api_base_url": "http://127.0.0.1:3876",
}, },
} }