task new: einen zweiten Schreibweg in den Tracker (ein Posten, keine Seite, #132)
Files changed: - CHANGES.md - VERSION - docs/knowledge-and-commitment.md - instructions/wiki-ingest/SKILL.md - tools/CONTRACT.md - tools/chemenu/cli.py - tools/chemenu/commands/task_cmd.py - tools/chemenu/tasks/protocol.py - tools/chemenu/tasks/superproductivity.py - tools/chemenu/tests/test_instructions_cmd.py - tools/chemenu/tests/test_superproductivity.py - tools/chemenu/tests/test_task_cmd.py
This commit is contained in:
1 parent
e07d1ca42a
commit
cfbe3ea83e
12 files changed
+845
-45
No files matched your search
@@ -164,6 +164,48 @@ class TaskWriter(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def create_item(
|
||||
self,
|
||||
title: str,
|
||||
*,
|
||||
project_name: Optional[str],
|
||||
waiting: bool = False,
|
||||
follow_up_at: Optional[date] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Create one open item - a tracker `Posten`, never a kb/ page
|
||||
(Gitea #132 D1). `title` is stored verbatim, exactly like
|
||||
`WaitingItem.title` - never parsed.
|
||||
|
||||
`project_name=None` is the caller's own explicit choice of the
|
||||
tracker's inbox (#132 D4 "Weg 3"), never a stand-in for "no project
|
||||
was given" - the CLI's own `--inbox` flag is the only thing allowed
|
||||
to produce it; an omitted `--project` is refused before this is ever
|
||||
called. A `project_name` that is given must already exist
|
||||
(case-normalized, #119 D8) - this never creates a project itself and
|
||||
never searches or guesses one (#132 D6): `chemenu.errors.ValidationError`
|
||||
if no such project exists.
|
||||
|
||||
`waiting`/`follow_up_at` set #119's own WAITING/`follow_up_at` pair
|
||||
(D9/D30) - the same two machine-readable parts `WaitingItem` reads
|
||||
back. Raises `ValidationError` if the provider can represent items at
|
||||
all (it offers `TaskWriter`) but has no way to mark one WAITING right
|
||||
now - e.g. Super Productivity's `waiting` tag does not exist yet and
|
||||
tags cannot be created via its API (#132's own verified constraint):
|
||||
an item is never created *without* the status it was asked for.
|
||||
|
||||
`notes` carries D5's freetext backref to a kb/ page - stored
|
||||
verbatim, never parsed, exactly the posture `WaitingItem.title`
|
||||
already has for the person named in it.
|
||||
|
||||
Unlike `create_project`, this never raises
|
||||
`chemenu.errors.HumanInterventionRequired`: every provider offering
|
||||
`TaskWriter` at all has been verified to have a real item-creation
|
||||
call (#132 - the gap `create_project` hits, no project-creation
|
||||
endpoint, does not exist on the item side).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def find_project(reader: TaskReader, name: str) -> Optional[ProjectSummary]:
|
||||
"""The project matching `name` case-normalized (#119 D8), or `None`.
|
||||
|
||||
@@ -108,6 +108,19 @@ from chemenu.tasks.protocol import (
|
||||
# - this instance's own convention, not something Super Productivity defines.
|
||||
WAITING_TAG_TITLE = "waiting"
|
||||
|
||||
# Super Productivity's own inbox project id, verified against
|
||||
# `project.const.ts`/`project.selectors.ts` on `master` (Gitea #132, 2026-09-20):
|
||||
# a real project entity the store adds to itself if missing
|
||||
# (`_addInboxProjectIfNecessary`), but `selectUnarchivedProjects` filters it out
|
||||
# unconditionally by this exact id - so it never appears in `GET /projects`
|
||||
# (nor in the snapshot path's own `project` entity state, which mirrors that
|
||||
# filtering, module docstring). `create_item`'s `--inbox` route is the only
|
||||
# place this module ever writes it; because of the same filter, an item filed
|
||||
# there is invisible to every `chemenu.review` check that walks
|
||||
# `TaskReader.projects()` - "Inbox" never appears as a project name to join
|
||||
# against, not merely one this instance chooses to exclude.
|
||||
INBOX_PROJECT_ID = "INBOX_PROJECT"
|
||||
|
||||
DEFAULT_API_BASE_URL = "http://127.0.0.1:3876"
|
||||
|
||||
ACCESS_API = "api"
|
||||
@@ -391,29 +404,42 @@ def _expect_list(value: Any, what: str) -> list[dict]:
|
||||
|
||||
|
||||
class _ApiClient:
|
||||
"""The one HTTP transport `SuperProductivityApiReader` uses - a thin,
|
||||
loudly-failing wrapper, not a general REST client."""
|
||||
"""The one HTTP transport `SuperProductivityApiReader`/`SuperProductivityWriter`
|
||||
use - a thin, loudly-failing wrapper, not a general REST client. `get` and
|
||||
`post` (Gitea #132) share one request/error path, so a shape drift or a
|
||||
new failure mode only needs handling once."""
|
||||
|
||||
def __init__(self, cfg: SuperProductivityConfig):
|
||||
self._cfg = cfg
|
||||
|
||||
def get(self, path: str, *, timeout: float = 10.0) -> Any:
|
||||
return self._request("GET", path, timeout=timeout)
|
||||
|
||||
def post(self, path: str, body: dict, *, timeout: float = 10.0) -> Any:
|
||||
return self._request("POST", path, body=body, timeout=timeout)
|
||||
|
||||
def _request(
|
||||
self, method: str, path: str, *, body: Optional[dict] = None, 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}"}
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {self._cfg.api_token}"}
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
||||
body = response.read()
|
||||
response_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."
|
||||
f"superproductivity: API answered 503 APP_NOT_READY for "
|
||||
f"{method} {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}."
|
||||
f"superproductivity: API returned HTTP {exc.code} for {method} {path}."
|
||||
) from exc
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
raise ValidationError(
|
||||
@@ -421,10 +447,10 @@ class _ApiClient:
|
||||
"Is Super Productivity running?"
|
||||
) from exc
|
||||
try:
|
||||
return json.loads(body)
|
||||
return json.loads(response_body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValidationError(
|
||||
f"superproductivity: API returned unparseable JSON for {path}."
|
||||
f"superproductivity: API returned unparseable JSON for {method} {path}."
|
||||
) from exc
|
||||
|
||||
|
||||
@@ -507,15 +533,17 @@ class SuperProductivityApiReader:
|
||||
|
||||
|
||||
class SuperProductivityWriter:
|
||||
"""`TaskWriter` over the local REST API - except there is no API call
|
||||
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."""
|
||||
"""`TaskWriter` over the local REST API. `create_project` never actually
|
||||
creates anything - see the module docstring; `create_item` (Gitea #132)
|
||||
does, since `POST /tasks` exists where `POST /projects` does not. 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):
|
||||
self._cfg = cfg
|
||||
self._reader = reader
|
||||
self._client = _ApiClient(cfg)
|
||||
|
||||
def create_project(self, name: str) -> None:
|
||||
"""Never creates anything. Preflights the name against the read path
|
||||
@@ -542,3 +570,73 @@ class SuperProductivityWriter:
|
||||
" 3. Tell the agent you have done this, so it can re-check and continue.",
|
||||
verify=_verify,
|
||||
)
|
||||
|
||||
def create_item(
|
||||
self,
|
||||
title: str,
|
||||
*,
|
||||
project_name: Optional[str],
|
||||
waiting: bool = False,
|
||||
follow_up_at: Optional[date] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> None:
|
||||
"""`POST /tasks` (Gitea #132) - the endpoint `create_project` cannot
|
||||
reach an equivalent of. Resolves every precondition (the target
|
||||
project's own id, the `waiting` tag's own id) before making the one
|
||||
write, so a missing precondition never leaves behind a half-written
|
||||
item - no task without the WAITING status it was asked for."""
|
||||
if project_name is None:
|
||||
project_id = INBOX_PROJECT_ID
|
||||
else:
|
||||
if find_project(self._reader, project_name) is None:
|
||||
raise ValidationError(
|
||||
f"No project named '{project_name}' (case-insensitively) exists in Super "
|
||||
"Productivity - this command does not create one (Gitea #132 D6). Run "
|
||||
"`wikitool new project` first, or pass --inbox."
|
||||
)
|
||||
project_id = self._project_id(project_name)
|
||||
|
||||
body: dict[str, Any] = {"title": title, "projectId": project_id}
|
||||
if notes:
|
||||
body["notes"] = notes
|
||||
if waiting:
|
||||
body["tagIds"] = [self._waiting_tag_id()]
|
||||
if follow_up_at is not None:
|
||||
body["dueDay"] = follow_up_at.isoformat()
|
||||
|
||||
self._client.post("/tasks", body)
|
||||
|
||||
def _project_id(self, project_name: str) -> str:
|
||||
"""Super Productivity's own id for `project_name`, read fresh from the
|
||||
API. `ProjectSummary` (the protocol-level read shape every provider
|
||||
shares) deliberately carries no id - not every provider has one - so
|
||||
a writer that needs one reads it itself here rather than the generic
|
||||
read path growing an SP-specific field for this one caller."""
|
||||
target = normalize_project_name(project_name)
|
||||
for record in _expect_list(self._client.get("/projects"), "/projects"):
|
||||
if normalize_project_name(str(record.get("title", ""))) == target:
|
||||
project_id = record.get("id")
|
||||
if isinstance(project_id, str) and project_id:
|
||||
return project_id
|
||||
raise ValidationError(
|
||||
f"superproductivity: project '{project_name}' matched the read path moments ago but "
|
||||
"its API record now has no usable id - the response shape does not match what this "
|
||||
"adapter expects."
|
||||
)
|
||||
|
||||
def _waiting_tag_id(self) -> str:
|
||||
"""The `waiting` tag's own id, or a loud refusal (Gitea #132's own
|
||||
acceptance criterion): tags cannot be created via this API (only
|
||||
`GET /tags` exists, module docstring), so a WAITING item is never
|
||||
created without its status - the precondition is checked before
|
||||
`POST /tasks` is ever called, not patched up after."""
|
||||
for record in _expect_list(self._client.get("/tags"), "/tags"):
|
||||
if str(record.get("title", "")).strip().casefold() == WAITING_TAG_TITLE:
|
||||
tag_id = record.get("id")
|
||||
if isinstance(tag_id, str) and tag_id:
|
||||
return tag_id
|
||||
raise ValidationError(
|
||||
f"superproductivity: no tag named '{WAITING_TAG_TITLE}' exists - tags cannot be "
|
||||
"created via the API (only GET /tags, Gitea #132). Create it in Super Productivity "
|
||||
"first, then retry."
|
||||
)
|
||||
Reference in new issue
Block a user