Files
cleveragents-core/tools/controller/_json_safe.py
T
drew c8677d985d fix(controller): batch J — round-3 cleanup (R3, R4, R5, R6, R8, R10)
R3 — delete unused schema columns:
``workflows.ci_flake_retries_remaining`` and
``workflows.awaiting_ci_started_at`` shipped in round-1 batch D as
"staged for future tick handlers" — but no producer or reader ever
used them. ci_poll.py uses ``entered_state_at`` (already populated
on every transition) as the AWAITING_CI start timestamp. Delete
both columns; the flake-retries counter should ship with its
producer, not as speculative schema.

R4 — safe_json_dumps raises on sets:
Previously sets/frozensets were silently coerced to sorted lists.
This contradicted the "raise loudly" design intent (JSON has no
native set; a reader doing ``parsed["tags"]`` would get a list,
losing set algebra). Now raises with an actionable message
pointing the producer at ``sorted(list(...))`` for explicit
conversion.

R5 — TestSQLiteConcurrentDequeue docstring honest:
The test name implied it pinned SQLite's busy_timeout retry. It
doesn't — :memory: + StaticPool means both threads share one
connection (SQLAlchemy serializes per-connection). Updated
docstring to say what the test ACTUALLY pins (Python-level
serialization safety, exactly-one-winner, clean loser-reason) and
explicitly what it doesn't (cross-process SQLITE_BUSY retry,
which would need file-backed SQLite + QueuePool — not shipped
because multi-machine requires Postgres).

R6 — safety timer on test_loop_runs_ci_poll_exhaustion_on_cadence:
Previously the test relied entirely on on_iter setting stop when
workflows_exhausted>0. If the logic regressed (SQL schema drift,
on_iter never seeing the count), the test wedged CI indefinitely.
Now armed with threading.Timer(5.0, stop.set) safety net + an
assertion that surfaces the failure mode if the safety timer
fired first.

R8 — _json_safe scope documented honestly:
The docstring claimed "everywhere the controller serializes" but
the encoder is only wired at runner.py and scheduler.py — the two
sites that serialize WORKER-ORIGINATED payloads. Other json.dumps
call sites (event-row payloads, discovery markers) serialize
fixed-shape dicts of native types and don't need restriction.
Updated docstring to scope the claim accurately.

R10 — missing test assertions added:
- test_strict_parser_coverage_blocks_startup_with_stubs: now
  captures logs + asserts the operator-facing error message is
  emitted (so journald shows the cause; a silent rc=2 would be
  confusing).
- test_externally_merged_takes_priority_over_label_removal: now
  asserts event_type="external-merge" + reason="externally-merged"
  (a regression that transitioned correctly with the wrong reason
  in the audit trail would now be caught).
- test_second_pause_captures_post_resume_state (NEW): pause/resume/
  pause cycle. After RESUME + workflow advances to REVIEWING, a
  second PAUSE must capture REVIEWING as pre_pause_state (not the
  original IMPLEMENTING). Round-2 coverage only exercised first
  pause.

Total: 696 controller tests pass (+3 net from new + updated tests),
0 regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:35:17 -04:00

71 lines
2.6 KiB
Python

"""Safe JSON encoding helper.
Phase 1k++ refinement (N5/R4): we used to pass ``default=str`` to
``json.dumps`` so nested datetime/Decimal fields didn't crash the
write AFTER the agent had done its real work. But ``default=str`` is
too permissive — a worker that accidentally emits a ``set`` or a
custom class gets silently stringified to ``"<MyObj at 0x...>"``,
masking a real output bug.
This module ships a restricted encoder that:
- Encodes ``datetime`` and ``date`` as ISO-8601 strings.
- Encodes ``Decimal`` as its string repr (preserves precision).
- Encodes ``UUID`` as its canonical string form.
- Encodes ``Path`` as its string form.
- Raises ``TypeError`` for anything else — including ``set`` /
``frozenset`` (round 3 R4: silent set→list coercion contradicted
the "raise loudly" design intent because JSON has no native set
and a reader doing ``parsed["tags"]`` would get a list, losing
set algebra. Callers that genuinely want a JSON array should call
``sorted(list(my_set))`` explicitly).
Scope (round-3 R8): this encoder is used at the boundaries where
WORKER-ORIGINATED payloads are persisted — i.e., the runner's
output-write and the scheduler's input_payload write. Other
``json.dumps`` call sites in the controller (event-row payloads,
discovery markers, etc.) serialize fixed-shape dicts of native types
and don't need restriction.
"""
from __future__ import annotations
import json
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from uuid import UUID
# Public list of types this encoder coerces. Anything else → TypeError.
_SAFE_TYPES = (datetime, date, Decimal, UUID, Path)
def _restricted_default(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, Decimal):
return str(value)
if isinstance(value, UUID):
return str(value)
if isinstance(value, Path):
return str(value)
raise TypeError(
f"Object of type {type(value).__name__} is not JSON-serializable "
f"and not in the controller's safe-type allowlist "
f"({', '.join(t.__name__ for t in _SAFE_TYPES)}). "
f"To emit a set as a JSON array, call sorted(list(...)) "
f"explicitly at the producer."
)
def safe_json_dumps(obj: Any, **kwargs: Any) -> str:
"""json.dumps with a restricted ``default`` that handles the
allowlist + raises for anything else.
Extra kwargs are forwarded to ``json.dumps``."""
return json.dumps(obj, default=_restricted_default, **kwargs)
__all__ = ["safe_json_dumps"]