diff --git a/loopx/control_plane/runtime/time.py b/loopx/control_plane/runtime/time.py index 66282db3a3..dbf5eeaf7f 100644 --- a/loopx/control_plane/runtime/time.py +++ b/loopx/control_plane/runtime/time.py @@ -1,10 +1,68 @@ from __future__ import annotations from datetime import datetime, timezone +import re from typing import Any _MIN_TIMESTAMP = datetime.min.replace(tzinfo=timezone.utc) +_ISO_DATE_WITH_SEPARATOR = re.compile( + r"^(?P\d{4}-W\d{2}(?:-[1-7])?|\d{4}W\d{2}[1-7]?|" + r"\d{4}-\d{2}-\d{2}|\d{8})(?P[^Zz])(?P.+)$" +) +_ISO_CLOCK = re.compile(r"^(\d{2})(?:(:?)(\d{2})(?:\2(\d{2}))?)?([.,]\d+)?$") + + +def _stable_iso_timestamp_text(text: str) -> str | None: + """Normalize reduced fractional clocks and reject version-specific 24:00. + + Python 3.11–3.13 accept fractions after an hour or minute, while 3.14 + accepts 24:00 as end-of-day. Keep the retained reader's wire grammar stable + across supported Python versions: preserve the former inputs and reject the + latter because the TypeScript Todo codec does not carry an end-of-day date + rollover. + """ + match = _ISO_DATE_WITH_SEPARATOR.fullmatch(text) + if match is None: + return text + + clock = match.group("clock") + zone_suffix = "" + if clock.endswith(("Z", "z")): + clock, zone_suffix = clock[:-1], clock[-1:] + else: + zone_index = next((i for i, char in enumerate(clock) if i and char in "+-"), None) + if zone_index is not None: + clock, zone_suffix = clock[:zone_index], clock[zone_index:] + + def normalize_component(component: str, *, local: bool) -> str | None: + parsed = _ISO_CLOCK.fullmatch(component) + if parsed is None: + return component + hour, separator, minute, second, fraction = parsed.groups() + if local and hour == "24": + return None + if not local and hour == "00" and minute in (None, "00") and second in (None, "00"): + # CPython 3.11–3.13 canonicalize a zero-hour offset to UTC even + # when a fractional component follows it. Keep that wire meaning + # on 3.14 too, matching the TypeScript codec's zero-offset rule. + return "00:00:00" + if fraction is None or second is not None: + return component + return f"{hour}:{minute or '00'}:00{fraction}" + + normalized_clock = normalize_component(clock, local=True) + if normalized_clock is None: + return None + if zone_suffix.startswith(("+", "-")): + normalized_offset = normalize_component(zone_suffix[1:], local=False) + if normalized_offset is None: + return text + zone_suffix = zone_suffix[0] + normalized_offset + + return ( + match.group("date") + match.group("separator") + normalized_clock + zone_suffix + ) def parse_timestamp(value: Any) -> datetime | None: @@ -13,8 +71,13 @@ def parse_timestamp(value: Any) -> datetime | None: text = str(value).strip() if not text: return None + stable_text = _stable_iso_timestamp_text(text) + if stable_text is None: + return None try: - parsed = datetime.fromisoformat(text.replace("Z", "+00:00").replace("z", "+00:00")) + parsed = datetime.fromisoformat( + stable_text.replace("Z", "+00:00").replace("z", "+00:00") + ) except ValueError: return None if parsed.tzinfo is None: diff --git a/loopx/control_plane/runtime_timestamp.ts b/loopx/control_plane/runtime_timestamp.ts index 668e29eb28..44cc8ffe06 100644 --- a/loopx/control_plane/runtime_timestamp.ts +++ b/loopx/control_plane/runtime_timestamp.ts @@ -69,9 +69,12 @@ export function parseTodoTimestampMicros(value: string): bigint | null { const parsed = /^(\d{2})(?:(:?)(\d{2})(?:\2(\d{2}))?)?(?:[.,](\d+))?$/.exec(raw); if (!parsed) return null; const hour = Number(parsed[1]), minute = Number(parsed[3] ?? 0), second = Number(parsed[4] ?? 0); - if (!offset && (hour > 23 || minute > 59 || second > 59)) return null; + const fraction = parsed[5] ?? ""; + if (!offset && ( + hour > 23 || minute > 59 || second > 59 + )) return null; const seconds = hour * 3600 + minute * 60 + second; - const micros = BigInt(seconds) * 1000000n + BigInt((parsed[5] ?? "").padEnd(6, "0").slice(0, 6)); + const micros = BigInt(seconds) * 1000000n + BigInt(fraction.padEnd(6, "0").slice(0, 6)); if (offset && micros >= 86400000000n) return null; // Python treats an all-zero offset as UTC even with fractional seconds. return offset && seconds === 0 ? 0n : micros; diff --git a/tests/control_plane/test_monitor_state_owner.py b/tests/control_plane/test_monitor_state_owner.py index 0432a6b510..386a32c2c4 100644 --- a/tests/control_plane/test_monitor_state_owner.py +++ b/tests/control_plane/test_monitor_state_owner.py @@ -81,6 +81,7 @@ def test_monitor_effect_replay_and_generation_are_locked_public_writer_semantics "2030-01-01T12:34:56.123456+08:00", "20300101T123456,123456+0800", "1970-01-01T01:00:00+00:59:59.999999", "1970-01-01T00.1", "1970-02-30", "2021-W53", "1970-01-01T24:00:00", "1970-01-01T01:00+24:00", + "9999-12-31T24:00:00", "9999-12-31T24:00:00Z", "9999-12-31T24:00:00.000000", "1970-01-01T00:0000", "1970-01-01T0000:00", "tomorrow", "2030", ]) def test_monitor_timestamp_input_matches_retained_python_iso_codec(value): @@ -89,7 +90,7 @@ def test_monitor_timestamp_input_matches_retained_python_iso_codec(value): "role": "agent", "task_class": "continuous_monitor", "enforce_boundedness": True, "metadata": {"expires_at": value}, } - # The pre-migration stdlib codec is an independent input-compatibility oracle. + # The retained Python reader defines the stable cross-version wire grammar. if parse_timestamp(value) is None: with pytest.raises(EffectRuntimeRejected, match="timestamp"): effect_runtime_result("todo.monitor_metadata.plan", request) @@ -98,6 +99,39 @@ def test_monitor_timestamp_input_matches_retained_python_iso_codec(value): assert result["metadata"]["expires_at"] == value +@pytest.mark.parametrize(("value", "expected_utc"), [ + ("2030-01-01T00.1", "2030-01-01T00:00:00.100000+00:00"), + ("2030-01-01T00:00.1", "2030-01-01T00:00:00.100000+00:00"), + ("2030-01-01T00:00:00+01.1", "2029-12-31T22:59:59.900000+00:00"), + ("2030-01-01T00:00:00+00.1", "2030-01-01T00:00:00+00:00"), + ("2030-01-01T00:00:00-00.1", "2030-01-01T00:00:00+00:00"), + ("2030-01-01T00:00:00+00:00:00.1", "2030-01-01T00:00:00+00:00"), + ("2030-01-01T00.1+00.5", "2030-01-01T00:00:00.100000+00:00"), + ("2030-01-01T00:00:00.1+00:00:00.5", "2030-01-01T00:00:00.100000+00:00"), +]) +def test_monitor_timestamp_codec_is_stable_across_supported_python_versions(value, expected_utc): + parsed = parse_timestamp(value) + assert parsed is not None + assert parsed.isoformat() == expected_utc + result = effect_runtime_result("todo.monitor_metadata.plan", { + "schema_version": "loopx_todo_monitor_metadata_request_v0", + "role": "agent", "task_class": "continuous_monitor", "enforce_boundedness": True, + "metadata": {"expires_at": value}, + }) + assert result["metadata"]["expires_at"] == value + + +def test_monitor_timestamp_codec_rejects_version_specific_end_of_day(): + value = "2030-01-01T24:00:00" + assert parse_timestamp(value) is None + with pytest.raises(EffectRuntimeRejected, match="timestamp"): + effect_runtime_result("todo.monitor_metadata.plan", { + "schema_version": "loopx_todo_monitor_metadata_request_v0", + "role": "agent", "task_class": "continuous_monitor", "enforce_boundedness": True, + "metadata": {"expires_at": value}, + }) + + @pytest.mark.parametrize("date", ["1970-01-01", "19700101", "1970-W01-4"]) @pytest.mark.parametrize("separator", ["Z", "z"]) def test_public_writer_rejects_timezone_letter_as_date_separator(tmp_path, date, separator): diff --git a/tests/control_plane_ts/monitor_metadata.test.ts b/tests/control_plane_ts/monitor_metadata.test.ts index cdd42903a3..36b05b5b00 100644 --- a/tests/control_plane_ts/monitor_metadata.test.ts +++ b/tests/control_plane_ts/monitor_metadata.test.ts @@ -100,9 +100,12 @@ test("poll rejects invalid observation shapes without mutating its input", () => }); test("timestamp codec rejects rollover dates and ordering retains microseconds", () => { - for (const invalid of ["2030-02-30T00:00:00Z", "2030-01-01T24:00:00Z", "2030"]) { + for (const invalid of ["2030-02-30T00:00:00Z", "2030"]) { assert.throws(() => planMonitorMetadata(request({metadata: {expires_at: invalid}})), /timestamp/); } + assert.throws(() => planMonitorMetadata(request({ + metadata: {expires_at: "2030-01-01T24:00:00Z"}, + })), /timestamp/); assert.throws(() => planMonitorMetadata(request({ existing: {...existing, last_checked_at: "2030-01-01T01:00:00.000002Z"}, observation: observation({generated_at: "2030-01-01T01:00:00.000001Z", monitor_effect_id: null}), @@ -155,7 +158,20 @@ test("Todo timestamp codec retains Python ISO compatibility, timezone seconds an "1970-01-01T01:00:00+00:59:59.999999"]) { assert.equal(parseTodoTimestampMicros(value), 1n, value); } - for (const value of ["1970-02-30", "2021-W53", "1970-01-01T24:00:00", "1970-01-01T01:00+24:00", + for (const value of ["1970-01-01T00.1", "1970-01-01T00:00.1"]) { + assert.equal(parseTodoTimestampMicros(value), 100000n, value); + } + assert.equal(parseTodoTimestampMicros("1970-01-01T00:00:00+01.1"), -3600100000n); + for (const value of ["1970-01-01T00:00:00+00.1", "1970-01-01T00:00:00-00.1", + "1970-01-01T00:00:00+00:00:00.1"]) { + assert.equal(parseTodoTimestampMicros(value), 0n, value); + } + for (const value of ["1970-01-01T00.1+00.5", "1970-01-01T00:00:00.1+00:00:00.5"]) { + assert.equal(parseTodoTimestampMicros(value), 100000n, value); + } + for (const value of ["1970-02-30", "2021-W53", "1970-01-01T01:00+24:00", + "1970-01-01T24:00:00", "1970-01-01T24:00:01", + "9999-12-31T24:00:00", "9999-12-31T24:00:00Z", "9999-12-31T24:00:00.000000", "1970-01-01T00:0000", "1970-01-01T0000:00", "tomorrow", "2030"]) { assert.equal(parseTodoTimestampMicros(value), null, value); }