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
65 changes: 64 additions & 1 deletion loopx/control_plane/runtime/time.py
Original file line number Diff line number Diff line change
@@ -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<date>\d{4}-W\d{2}(?:-[1-7])?|\d{4}W\d{2}[1-7]?|"
r"\d{4}-\d{2}-\d{2}|\d{8})(?P<separator>[^Zz])(?P<clock>.+)$"
)
_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:
Expand All @@ -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:
Expand Down
7 changes: 5 additions & 2 deletions loopx/control_plane/runtime_timestamp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 35 additions & 1 deletion tests/control_plane/test_monitor_state_owner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -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):
Expand Down
20 changes: 18 additions & 2 deletions tests/control_plane_ts/monitor_metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}),
Expand Down Expand Up @@ -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);
}
Expand Down
Loading