Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

- Automation control no longer treats an empty or id-less `automation.toml` as a readable record (T-20261107-490692877) on 2026-11-07:
- A truncated file parses as valid TOML. It used to count as a record with status `UNKNOWN`, and `activate-all` would have written `status = "ACTIVE"` into it, "repairing" a broken definition with a status. Now an empty file, a file with only a comment or whitespace, a missing, empty or non-string `id`, and an `id` that differs from the folder name are unparseable with their own message (`empty`, `missing id`, `id mismatch`). Pause, restore and activate-all never write into them; `_set_status()` checks the file again right before writing.
- `find_automation_anomalies()` reports empty and id-less files as unreadable too; an `id` that differs from the folder name stays a normal finding there (duplicate-id detection).
- `_write_toml()` copies the permissions of the original to the temp file before `os.replace` (`mkstemp` creates 0600) and writes through a link: the target is replaced, the link stays.
- With a read-only original on Windows the temp file inherited the read-only attribute: `os.replace` failed (WinError 5), the cleanup failed too, `automation.toml.<x>.tmp` stayed behind, and the error text named only the temp file. The cleanup now makes the temp file writable first and never replaces the original error; the original stays unchanged and the error text ends with the target path (review finding on PR #10). The mode stays set before the replace on purpose, so the target never carries a wrong mode, not even briefly.
- Tests: `tests/test_automation_control.py` (46 new; the POSIX-mode and symlink tests are skipped on Windows without the right and were run on Linux).

- CodeQL runs through the GitHub default setup only; the own workflow `.github/workflows/codeql.yml` is removed (T-20261107-558181168) on 2026-11-07:
- The repository has the GitHub default setup for code scanning (python and actions, weekly, default query suite, configured since 2026-07-22). The workflow from 2026-10-30 was a second, "advanced" configuration, and its `analyze` job failed on every run (push, pull request, schedule) with "advanced configurations cannot be processed when the default setup is enabled". The default setup already covers Python and GitHub Actions; no coverage is lost.
- `tests/test_metadata.py`: the contract test that required the workflow now forbids it and any `github/codeql-action` step. To switch to an advanced setup later, change the repository setting first and add the workflow in the same step.
Expand Down
76 changes: 62 additions & 14 deletions src/codex_logdatenbank_wartung/automation_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import json
import os
import stat
import tempfile
import time
from collections.abc import Callable
Expand All @@ -35,9 +36,18 @@
ProgressCallback = Callable[[int, int, str], None]
Sleeper = Callable[[float], None]

# Was beim Lesen einer automation.toml schiefgehen kann: Ein-/Ausgabe, kein gueltiges UTF-8
# oder ein TOML-Fehler (tomlkit meldet doppelte Schluessel als ParseError).
_UNREADABLE = (OSError, UnicodeDecodeError, tomlkit.exceptions.TOMLKitError)


class AutomationDefinitionError(ValueError):
"""Die Datei ist gueltiges TOML, aber keine brauchbare Automations-Definition.

Die Meldung beginnt mit ``empty``, ``missing id`` oder ``id mismatch``.
"""


# Was beim Lesen einer automation.toml schiefgehen kann: Ein-/Ausgabe, kein gueltiges UTF-8,
# ein TOML-Fehler (tomlkit meldet doppelte Schluessel als ParseError) oder eine leere bzw. id-lose Definition.
_UNREADABLE = (OSError, UnicodeDecodeError, tomlkit.exceptions.TOMLKitError, AutomationDefinitionError)


@dataclass(slots=True)
Expand Down Expand Up @@ -153,22 +163,56 @@ def _read_toml(path: Path) -> tomlkit.TOMLDocument:
return tomlkit.parse(path.read_bytes().decode("utf-8"))


def _read_automation(path: Path, *, match_folder: bool = True) -> tomlkit.TOMLDocument:
"""Liest eine ``automation.toml`` und verlangt eine brauchbare Definition.

Eine leere, abgeschnittene oder id-lose Datei parst als gueltiges TOML; ohne diese Pruefung galt sie
als Datensatz mit Status UNKNOWN, und ``activate-all`` haette ``status = "ACTIVE"`` hineingeschrieben.
``match_folder=False`` laesst eine ``id`` zu, die vom Ordnernamen abweicht (die Doppel-id-Erkennung
braucht genau diesen Fall); alles, was Dateien aendert, verlangt die Uebereinstimmung.
"""
data = _read_toml(path)
if not data:
raise AutomationDefinitionError("empty: keine Schluessel (leere Datei, nur Kommentar oder nur Leerraum)")
raw_id = data.get("id")
automation_id = raw_id.strip() if isinstance(raw_id, str) else ""
if not automation_id:
raise AutomationDefinitionError("missing id: 'id' fehlt, ist leer oder keine Zeichenkette")
if match_folder and automation_id.casefold() != path.parent.name.casefold():
raise AutomationDefinitionError(
f"id mismatch: id '{automation_id}' weicht vom Ordnernamen '{path.parent.name}' ab"
)
return data


def _write_toml(path: Path, document: tomlkit.TOMLDocument) -> None:
"""Schreibt atomar: erst vollstaendig kodieren, dann tmp im selben Ordner, fsync, ``os.replace``.

Bricht der Vorgang ab, bleibt die Originaldatei unveraendert und die tmp-Datei wird entfernt.
Bricht der Vorgang ab, bleibt die Originaldatei unveraendert und die tmp-Datei wird entfernt (auch eine
schreibgeschuetzte); der urspruengliche Fehler wird nicht durch einen Aufraeumfehler ersetzt.
``mkstemp`` legt die Datei mit 0600 an: die Rechte des Originals kommen vor dem Austausch auf die tmp-Datei.
Ist ``path`` ein Link, wird sein Ziel ersetzt und der Link bleibt bestehen.
"""
payload = tomlkit.dumps(document).encode("utf-8")
descriptor, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f"{path.name}.", suffix=".tmp")
target = Path(os.path.realpath(path))
mode = stat.S_IMODE(target.stat().st_mode)
descriptor, tmp_name = tempfile.mkstemp(dir=target.parent, prefix=f"{target.name}.", suffix=".tmp")
temporary = Path(tmp_name)
try:
with os.fdopen(descriptor, "wb") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
os.chmod(temporary, mode)
os.replace(temporary, target)
except BaseException:
temporary.unlink(missing_ok=True)
# Die tmp-Datei kann das Nur-Lesen-Attribut des Originals tragen (Windows): ohne chmod scheitert unlink,
# laesst die Datei liegen und ueberdeckt den eigentlichen Fehler. Dessen Text bleibt der, der zaehlt.
try:
os.chmod(temporary, stat.S_IWRITE | stat.S_IREAD)
temporary.unlink(missing_ok=True)
except OSError:
pass
raise


Expand All @@ -177,6 +221,8 @@ def _quoted_string(value: object) -> str:


def _unreadable_text(exc: Exception) -> str:
if isinstance(exc, AutomationDefinitionError):
return " ".join(str(exc).split())[:300] # beginnt mit empty / missing id / id mismatch
return f"{type(exc).__name__}: {' '.join(str(exc).split())}"[:300]


Expand All @@ -197,15 +243,15 @@ def scan_automations(
unparseable: list[UnparseableAutomation] = []
for toml_path in sorted(root.glob("*/automation.toml")):
try:
data = _read_toml(toml_path)
data = _read_automation(toml_path)
except _UNREADABLE as exc:
unparseable.append(
UnparseableAutomation(
id=toml_path.parent.name, path=toml_path, error=_unreadable_text(exc)
)
)
continue
automation_id = _quoted_string(data.get("id")) or toml_path.parent.name
automation_id = _quoted_string(data.get("id")).strip()
records.append(
AutomationRecord(
id=automation_id,
Expand Down Expand Up @@ -234,8 +280,10 @@ def find_automation_anomalies(config: MaintenanceConfig) -> AutomationAnomalyRep
* **doppelte id** = derselbe ``id``-Wert in mehreren ``automation.toml`` (ueber
alle Tiefenebenen). Ein rekursiver Scan in der Codex-App zaehlt die Automation
dadurch doppelt.
* **unlesbar** = ``automation.toml`` mit TOML- oder Kodierungsfehler (Pfad und Fehlertext
im Befund). Sie zaehlt weder bei Verschachtelung noch bei Duplikaten mit.
* **unlesbar** = ``automation.toml`` mit TOML- oder Kodierungsfehler oder ohne brauchbare
Definition (leer, nur Kommentar, ``id`` fehlt oder leer; Pfad und Fehlertext im Befund). Sie
zaehlt weder bei Verschachtelung noch bei Duplikaten mit. Eine ``id``, die vom Ordnernamen
abweicht, ist hier ein normaler Befund (Doppel-id), kein Lesefehler.

Aendert nichts auf der Platte und laesst ``load_automations`` (Tiefe 1) unberuehrt.
"""
Expand All @@ -247,7 +295,7 @@ def find_automation_anomalies(config: MaintenanceConfig) -> AutomationAnomalyRep
findings: list[AutomationFinding] = []
for toml_path in sorted(root.rglob("automation.toml")):
try:
data = _read_toml(toml_path)
data = _read_automation(toml_path, match_folder=False)
except _UNREADABLE as exc:
report.unparseable.append(
UnparseableAutomation(
Expand All @@ -259,7 +307,7 @@ def find_automation_anomalies(config: MaintenanceConfig) -> AutomationAnomalyRep
depth = len(toml_path.parent.relative_to(root).parts)
except ValueError:
continue
automation_id = _quoted_string(data.get("id")) or toml_path.parent.name
automation_id = _quoted_string(data.get("id")).strip()
findings.append(AutomationFinding(id=automation_id, path=toml_path, depth=depth))

report.nested = [
Expand Down Expand Up @@ -316,7 +364,7 @@ def _append_event(config: MaintenanceConfig, result: AutomationControlResult) ->


def _set_status(record: AutomationRecord, status: str) -> bool:
data = _read_toml(record.path)
data = _read_automation(record.path) # erneut pruefen: die Datei kann seit dem Scan abgeschnitten worden sein
current = _quoted_string(data.get("status")).strip().upper()
if current == status.strip().upper():
return False
Expand Down
Loading
Loading