Skip to content

feat(listReservations): add from/to time-window filter (v0.1.25.19, closes #159)#160

Merged
amavashev merged 5 commits into
mainfrom
feat/issue-159-listreservations-from-to
May 21, 2026
Merged

feat(listReservations): add from/to time-window filter (v0.1.25.19, closes #159)#160
amavashev merged 5 commits into
mainfrom
feat/issue-159-listreservations-from-to

Conversation

@amavashev
Copy link
Copy Markdown
Collaborator

Closes #159. Implements cycles-protocol-v0.yaml revision 2026-05-21 (runcycles/cycles-protocol#97, merged 2026-05-21).

Summary

Two new optional query parameters on GET /v1/reservations:

  • from: ISO 8601 date-time, inclusive lower bound on created_at_ms.
  • to: ISO 8601 date-time, inclusive upper bound on created_at_ms.

Either may be supplied alone (open interval) or together (closed window). The filter always binds to created_at_ms regardless of sort_by, so sort_by=expires_at_ms&from=…&to=… returns reservations created in the window, ordered by expiry. Pure additive wire change — clients that don't send the new params get the v0.1.25.18 response byte-for-byte.

Why

The old "fetch last 24h of reservations" workflow required clients to sort by created_at_ms and walk pages, doubling page size on each retry until the trailing row fell outside the window. For high-volume agent clusters that's O(N) for an O(window) operation. With server-side from/to, the scan is boundaried before hydration and cursor pagination over that window stays cursor-stable.

Implementation

  • Controller (ReservationController.list) — parses ISO-8601 via Instant.parse. Malformed values → 400 INVALID_REQUEST (Invalid from: … / Invalid to: …). from > to → 400 before the repository call, with verify(repository, never()) locking that boundary. Blank strings treated as unset.
  • Repository (RedisReservationRepository.listReservations, listReservationsSorted) — window predicate applied as a per-row filter in both the legacy SCAN-cursor path and the sorted path, after existing scope/status/tenant predicates and before hydration. Shared createdAtInWindow(fields, fromMs, toMs) helper. Missing/unparseable created_at rows defensively excluded when either bound is supplied.
  • FilterHasherhash(...) now takes trailing Long fromMs, Long toMs. They're folded into the canonical string after the eight existing string fields, so a cursor issued under one (from, to) returns 400 if re-used under a different window — same contract as v0.1.25.12 sort_by/sort_dir/filter-mismatch.
  • Java signature changelistReservations(...) gains trailing Long fromMs, Long toMs (12 → 14 args). All Java callers updated. No wire format change.

Validation rules

Input Result
from=not-a-date 400 INVALID_REQUEST, message Invalid from: …
to=not-a-date 400 INVALID_REQUEST, message Invalid to: …
from > to 400 INVALID_REQUEST, message from must be less than or equal to to. No repository call.
from alone Open-upper-bound window
to alone Open-lower-bound window
from == to Closed point-window, accepted
from=&to= (blank) Treated as unset; no 400
Row with missing/unparseable created_at, window active Excluded
Row with missing/unparseable created_at, window inactive Unchanged behavior (filter no-ops)

Coverage

537 tests pass (374 data + 163 api) under mvn test. 16 new tests:

  • FilterHasherTest: 2 new (from/to distinct hash, positional swap).
  • RedisReservationQueryTest: 7 new under TimeWindowFilter nested class — legacy path from/to/inclusive-bounds, sorted path window, cursor-mismatch-on-window-change, missing created_at defensive, unparseable created_at defensive.
  • ReservationControllerTest: 7 new under ListReservations nested class — malformed-from, malformed-to, reversed-window, from-only, to-only, equal-bounds, sort-key independence, blank-as-unset.

Tests were run with -Dcontract.spec.url=file:///path/to/local/cycles-protocol-v0.yaml due to a local SSL cert quirk; CI hits the default raw.githubusercontent.com URL which now resolves to the merged spec carrying from/to.

Out of scope

The issue rationale mentions cleanup of expired/abandoned reservations as a use case — that actually wants expires_at or finalized_at window filters, not created_at. Flagged on the issue thread; leaving it as a follow-up to keep this change reviewable.

Test plan

  • CI green (lint, build, full test suite against the merged spec)
  • Manual smoke against a running server: curl '…/v1/reservations?from=…&to=…', then curl '…&from=…&to=…&sort_by=expires_at_ms'
  • Reviewer sign-off on the AUDIT.md narrative + CHANGELOG entry
  • After merge: cycles-client-{python,typescript,rust} regen pulls in the additive params (no breaking change)

@amavashev
Copy link
Copy Markdown
Collaborator Author

amavashev commented May 21, 2026

Code review

Found 2 issues:

  1. FilterHasher canonical-form change breaks v0.1.25.18 sorted-path cursor continuity. The new code unconditionally appends |fr=…|to=… to the canonical string, even when both bounds are null. SHA-256 of t=acme|i=|st=|ws=|ap=|wf=|ag=|ts= (old) ≠ SHA-256 of t=acme|i=|st=|ws=|ap=|wf=|ag=|ts=|fr=|to= (new). Any SortedListCursor issued by v0.1.25.12–v0.1.25.18 carries the old 8-field hash; when re-presented to v0.1.25.19 — even with no from/to params — listReservationsSorted rejects it with 400 INVALID_REQUEST "cursor is not valid". This falsifies the AUDIT.md claim that v0.1.25.x clients omitting the new params continue byte-for-byte. Fix: only append |fr=…|to=… when at least one of fromMs / toMs is non-null, preserving the original 8-field canonical form for the no-window case.

canonical.append("wf=").append(nullSafe(workflow)).append('|');
canonical.append("ag=").append(nullSafe(agent)).append('|');
canonical.append("ts=").append(nullSafe(toolset)).append('|');
canonical.append("fr=").append(nullSafeLong(fromMs)).append('|');
canonical.append("to=").append(nullSafeLong(toMs));
try {

  1. Three new controller tests assert nothing — wrong literal epoch-ms. 1779710400000L is 2026-05-25T12:00:00Z, not 2026-05-21T12:00:00Z (correct value is 1779364800000L, off by 4 days). In shouldPropagateFromOnly, shouldPropagateToOnly, and shouldAcceptEqualBounds, the Mockito eq(1779710400000L) matcher never fires against the controller's actual computed ms; the stub returns null; the controller returns ResponseEntity.ok(null) which is still HTTP 200; the tests pass vacuously without verifying epoch-ms propagation at all. Fix: replace 1779710400000L with 1779364800000L (or compute it from Instant.parse(...).toEpochMilli() in the test setup to eliminate the hard-coded literal entirely).

@DisplayName("from only → epoch-ms propagates to repository")
void shouldPropagateFromOnly() throws Exception {
ReservationListResponse resp = ReservationListResponse.builder()
.reservations(Collections.emptyList()).hasMore(false).build();
// 2026-05-21T12:00:00Z = epoch ms 1779710400000
long expectedFromMs = 1779710400000L;
when(repository.listReservations(eq(TENANT), any(), any(), any(), any(), any(), any(), any(),
eq(50), any(), any(), any(), eq(expectedFromMs), eq((Long) null)))
.thenReturn(resp);
mockMvc.perform(get("/v1/reservations").param("from", "2026-05-21T12:00:00Z"))
.andExpect(status().isOk());
}
@Test
@DisplayName("to only → epoch-ms propagates to repository")
void shouldPropagateToOnly() throws Exception {
ReservationListResponse resp = ReservationListResponse.builder()
.reservations(Collections.emptyList()).hasMore(false).build();
long expectedToMs = 1779710400000L;
when(repository.listReservations(eq(TENANT), any(), any(), any(), any(), any(), any(), any(),
eq(50), any(), any(), any(), eq((Long) null), eq(expectedToMs)))
.thenReturn(resp);
mockMvc.perform(get("/v1/reservations").param("to", "2026-05-21T12:00:00Z"))
.andExpect(status().isOk());
}
@Test
@DisplayName("from = to (equal bounds, closed point window) → 200, both propagate")
void shouldAcceptEqualBounds() throws Exception {
ReservationListResponse resp = ReservationListResponse.builder()
.reservations(Collections.emptyList()).hasMore(false).build();
long expectedMs = 1779710400000L;
when(repository.listReservations(eq(TENANT), any(), any(), any(), any(), any(), any(), any(),
eq(50), any(), any(), any(), eq(expectedMs), eq(expectedMs)))
.thenReturn(resp);
mockMvc.perform(get("/v1/reservations")
.param("from", "2026-05-21T12:00:00Z")
.param("to", "2026-05-21T12:00:00Z"))
.andExpect(status().isOk());
}

Also considered but filtered out (real but lower-severity): legacy-path cursor doesn't invalidate on filter change — pre-existing across all filters (status/workspace/etc), the PR follows the same pattern; stale revision 2026-04-16 Javadoc on listReservations — doc nit; from > to check fires before admin-tenant-required check — pedantic ordering of two unrelated 400s.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

amavashev added 2 commits May 21, 2026 11:34
Closes #159. Implements cycles-protocol-v0.yaml revision 2026-05-21
(runcycles/cycles-protocol#97).

Two new optional query parameters on GET /v1/reservations:

  * from: ISO 8601 date-time. Inclusive lower bound on created_at_ms.
  * to:   ISO 8601 date-time. Inclusive upper bound on created_at_ms.

Either may be supplied alone (open interval) or together (closed
window). The filter always binds to created_at_ms regardless of
sort_by, so sort-by-expires_at_ms + window-by-created_at_ms is
well-defined.

Implementation:

  * Controller: parses ISO-8601 via Instant.parse; malformed → 400
    INVALID_REQUEST; from > to → 400 before any repository call;
    blank-string values treated as unset.
  * Repository: window predicate applied in both legacy SCAN-cursor
    and sorted paths. Predicate body shared via createdAtInWindow
    helper. Missing/unparseable created_at rows defensively excluded
    when either bound is supplied.
  * FilterHasher: fromMs/toMs folded into canonical hash so cursors
    invalidate on window change (same contract as v0.1.25.12
    sort_by/sort_dir/filter-mismatch).
  * Java signature change: listReservations gains trailing
    Long fromMs, Long toMs (12 → 14 args). No wire change for
    clients that omit the new params.

Coverage:

  * FilterHasherTest: 2 new cases (from/to distinct hash, positional).
  * RedisReservationQueryTest: 7 new cases under TimeWindowFilter
    nested class.
  * ReservationControllerTest: 7 new cases under ListReservations
    nested class (malformed-from/to, reversed-window, from-only,
    to-only, equal-bounds, sort-key independence, blank-as-unset).
  * 537 tests pass (374 data + 163 api).

Docs:

  * AUDIT.md: new dated entry walks through naming, sort-binding,
    cursor invalidation, validation choices, defensive read-side,
    coverage, and out-of-scope notes (expires_at follow-up).
  * CHANGELOG.md: [0.1.25.19] entry under Keep-a-Changelog format.
  * pom.xml revision bumped 0.1.25.18 → 0.1.25.19.
Two real bugs found during the post-push code-review pass against PR #160:

1. FilterHasher.hash unconditionally appended `|fr=...|to=...` to the
   canonical string, even when both bounds were null. This changed the
   SHA-256 hash for every pre-window cursor, including any v0.1.25.18
   sorted-path cursor mid-pagination across the deployment boundary —
   the server would reject those cursors with 400 INVALID_REQUEST
   despite the client never sending the new params. That falsified the
   PR's stated "byte-for-byte" back-compat claim for the sorted path.

   Fix: gate the fr=/to= emission behind `fromMs != null || toMs != null`.
   When both bounds are null the canonical form reverts byte-exactly to
   the v0.1.25.12 8-field shape, so any pre-window cursor continues to
   resolve. When either bound is supplied, the new fields render in the
   canonical string as before, so cursors still invalidate on window
   change. Adds an explicit regression test
   `preservesPreWindowHashWhenBothBoundsNull` that locks the truncated
   8-byte SHA-256 to its v0.1.25.18 golden value (2f397ea0e8fb53b7).

2. Three new ReservationControllerTest cases
   (shouldPropagateFromOnly / shouldPropagateToOnly / shouldAcceptEqualBounds)
   hard-coded 1779710400000L as the epoch-ms for "2026-05-21T12:00:00Z",
   but the correct value is 1779364800000L (the literal was off by 4
   days — it's actually 2026-05-25T12:00:00Z). Mockito's eq(...) never
   matched the controller's computed value, the stub silently returned
   null, ResponseEntity.ok(null) still produced HTTP 200, and the tests
   passed vacuously without asserting anything about epoch-ms propagation.

   Fix: drop the literal entirely. Derive the expected ms from the same
   parser the controller uses (java.time.Instant.parse(...).toEpochMilli()).
   Each test also gains a verify(repository).listReservations(... eq(...) ...)
   assertion so a future Mockito miss raises a wanted-but-not-invoked
   failure instead of slipping through as a vacuous 200.

No production behavior change beyond restoring the back-compat invariant
for v0.1.25.18 sorted-path cursors. All 538 protocol-service tests pass
(375 data + 163 api). JaCoCo 95% bundle gate met.

Also folds in the .19 → .20 revision bump from the rebase onto main
post-#161 (Tomcat CVE hygiene), so the PR's cumulative version is now
v0.1.25.20.
@amavashev amavashev force-pushed the feat/issue-159-listreservations-from-to branch from b3d3d26 to 4b122f7 Compare May 21, 2026 15:41
amavashev added 3 commits May 21, 2026 11:53
Two P3 follow-up findings from the human review pass:

1. cycles-protocol-service/README.md GET /v1/reservations parameter
   table omitted the new from / to query params. Added rows for both,
   plus a clarifying note that legacy SCAN cursors do not carry filter
   state (clients paginating with from / to but no sort_by must keep
   the window stable across pages — same convention the legacy path
   already enforces for status / workspace / app / etc).

2. CHANGELOG.md and AUDIT.md previously claimed "a cursor issued
   under one (from, to) returns 400 if re-used under a different
   window" without qualification, but the FilterHasher-driven check
   only applies to the sorted-path cursor (the SortedListCursor.fh
   field embedded in the opaque cursor returned when sort_by /
   sort_dir are supplied). Legacy SCAN cursors have no embedded
   filter state and are not window-validated. Qualified both docs to
   "sorted-path cursor invalidation" with an explicit legacy-path
   caveat so the contract reads accurately on first pass.

Also reconciled a stale AUDIT.md line that said "null values render
as empty in the canonical form" — the fix-up in 4b122f7 changed the
canonical form to omit the |fr=|to= appendix entirely when both
bounds are null, so the v0.1.25.18 8-field shape is preserved
byte-exactly. Reworded to reflect the gated-emission behavior and
cite the FilterHasherTest.preservesPreWindowHashWhenBothBoundsNull
regression test as the lockdown.

Bumped the in-doc test counts from 537 → 538 (374 data + 163 api →
375 data + 163 api) to reflect the new regression test added in the
fix-up commit. The v0.1.25.19 Tomcat-hygiene entry's count is
unchanged (correct at the time of that PR).

No code or test changes — pure documentation. No version bump
(continues v0.1.25.20).
The previous doc fixup (6082ba8) qualified the detailed "Sorted-path
cursor invalidation" section but left the parallel claim in the
top-of-file Date summary at AUDIT.md:3 unqualified. The summary still
read "cursors invalidate on window change" without distinguishing
sorted-path from legacy SCAN. Reviewer correctly flagged this as a
trailing inconsistency.

Updated to "sorted-path cursors invalidate on window change (the
legacy Redis-SCAN cursor is not window-validated, matching how it
already treats every other filter)." Wording now matches the
detailed section and the README.

No code or test changes. Pure summary-line correction. Still v0.1.25.20.
@amavashev amavashev enabled auto-merge May 21, 2026 16:03
@amavashev amavashev merged commit 8a7810e into main May 21, 2026
8 checks passed
@amavashev amavashev deleted the feat/issue-159-listreservations-from-to branch May 21, 2026 16:03
amavashev added a commit to runcycles/cycles-client-typescript that referenced this pull request May 21, 2026
…0.3.2) (#103)

Client-side companion to cycles-protocol-v0.yaml revision 2026-05-21
(runcycles/cycles-protocol#97) and runcycles/cycles-server#160. Closes
the TypeScript-client side of issue #159.

The existing `listReservations(params?: Record<string, string>)`
signature already forwards arbitrary keys to the URL query string,
so the new `from` and `to` ISO-8601 date-time params work over the
wire today without a code change. This commit adds a regression test
that pins the contract: URL-encoded form
"from=2026-05-21T00%3A00%3A00Z&to=2026-05-22T00%3A00%3A00Z" must
land in fetch's call. Future tightening of the Record signature
cannot silently drop the new params.

Unlike Python, TypeScript has no reserved-keyword issue — callers
can write `client.listReservations({ from: "...", to: "..." })`
directly.

No protocol or wire-format change; servers older than v0.1.25.20
silently ignore the new params per the additive-parameter guarantee
in cycles-protocol-v0.yaml. 316 tests pass at 98.4% statement
coverage / 99.62% line coverage (gate ≥95%).

Bumped to 0.3.2, updated AUDIT.md and CHANGELOG.md.
amavashev added a commit to runcycles/cycles-client-rust that referenced this pull request May 21, 2026
Client-side companion to cycles-protocol-v0.yaml revision 2026-05-21
(runcycles/cycles-protocol#97) and runcycles/cycles-server#160. Closes
the Rust-client side of issue #159.

`ListReservationsParams` is strongly-typed (unlike the loose
**kwargs / Record<string, string> shapes in the Python and TS
clients), so this PR adds two new `Option<String>` fields with
`#[serde(rename = ...)]` to project to the spec-mandated query-string
names:

  /// Inclusive lower bound on `created_at_ms`. ISO 8601 date-time.
  #[serde(rename = "from", skip_serializing_if = "Option::is_none")]
  pub from: Option<String>,

  /// Inclusive upper bound on `created_at_ms`. ISO 8601 date-time.
  #[serde(rename = "to", skip_serializing_if = "Option::is_none")]
  pub to: Option<String>,

Pure additive struct change. Callers using `ListReservationsParams::default()`
or `..Default::default()` continue to compile and behave identically;
new fields default to `None` and are skipped during serialization.

Adds `list_reservations_forwards_from_to_window` in tests/client_test.rs
that uses wiremock's `query_param` matcher to assert the new fields
land on the wire under the spec-mandated names ("from" / "to", not
the Rust struct field names — though they happen to match here).

Pre-existing drift on `ListReservationsParams` (missing `workspace` /
`workflow` / `toolset` subject filters, missing `sort_by` / `sort_dir`
from the v0.1.25.12 spec revision, missing `idempotency_key`) is NOT
addressed in this PR — kept scope tight. Worth a follow-up to bring
the struct to full v0.1.25 spec parity, but separate from the
#159 chain.

No protocol or wire-format change; servers older than v0.1.25.20
silently ignore the new params per the additive-parameter guarantee
in cycles-protocol-v0.yaml. 134 tests pass, clippy + doc-tests clean.

Bumped to 0.2.5, updated AUDIT.md and CHANGELOG.md.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Suggestion] Add created_at Range Filtering (before / after) to listReservations

1 participant