Skip to content

feat(control-plane): add indexed cursor-paginated request history - #1004

Merged
Wibias merged 4 commits into
lidge-jun:devfrom
Wibias:feat/ri-02-request-history-index
Aug 4, 2026
Merged

feat(control-plane): add indexed cursor-paginated request history#1004
Wibias merged 4 commits into
lidge-jun:devfrom
Wibias:feat/ri-02-request-history-index

Conversation

@Wibias

@Wibias Wibias commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

RI-02 of the Router Intelligence / Routing Control Plane programme
(devlog/_plan/260804_router_intelligence/000_master_plan.md). Adds a
rebuildable SQLite projection of the canonical usage.jsonl ledger plus a
cursor-paginated request-history API.

usage.jsonl remains the canonical, append-only request evidence. The index
(routing-history.sqlite in the config dir, Bun's built-in bun:sqlite) is a
derived query index that can be deleted, corrupted, or stale at any time and
is automatically rebuilt from the ledger without losing history (ADR-1/ADR-8).

Scope

  • src/routing/history/schema.ts - versioned schema (schema_meta +
    requests with filter indexes), HISTORY_SCHEMA_VERSION = 1.
  • src/routing/history/cursor.ts - opaque base64url keyset cursor
    (timestamp DESC, request_id DESC), InvalidCursorError.
  • src/routing/history/indexer.ts - open/append/rebuild/repair:
    • schema-version check, PRAGMA quick_check, source file identity
      (size/mtime), byte-offset incremental append;
    • handles: missing DB, corrupt DB (auto rebuild), old schema, partial final
      JSONL line (skipped until it completes), file replacement/truncation
      (identity change -> rebuild), duplicate replay (INSERT OR IGNORE),
      crash during indexing (transactional 500-row batches, WAL);
    • single-flight refresh; prepared statements finalized (Windows lock fix);
    • queryRequestHistory, requestHistoryRowById, rebuildRequestHistoryIndex.
  • src/server/management/request-history-routes.ts -
    • GET /api/request-history - filters: provider, model,
      requestedModel, status, conversationId, surface,
      inboundProtocol, apiKeyId, profileId, fallback, from, to;
      limit (1..100), cursor; response carries entries, nextCursor,
      hasMore, and an index status block (schemaVersion, indexedRows,
      sourceSize, sourceMtimeMs, builtAtMs, lastError).
    • GET /api/request-history/:requestId - one canonical row (404 unknown).
  • CLI: ocx logs rebuild-index, ocx logs index-status (run directly
    against the ledger; no running server needed).

Compatibility

  • usage.jsonl untouched; /api/logs contract unchanged.
  • The index is disposable: deleting it costs nothing beyond a rebuild.
  • Index rebuild is automatic on the next query; ocx logs rebuild-index
    forces one explicitly.

Privacy / security

  • The index only stores data already present in the canonical ledger; no new
    secret surface (same config dir, 0o600 semantics, local file).
  • Row payloads are the normalized persisted entries (already privacy-bounded
    by RI-01 and the usage-log normalizer); no prompts or credentials.
  • Invalid cursors and invalid filters return 400 with stable error codes.
  • bun run privacy:scan passes.

Dependency

Non-goals

  • No analytics endpoints (RI-03).
  • No policy profiles or scoring (RI-04..08).
  • No explainability API/CLI (RI-09), no GUI (RI-10).
  • No changes to /api/logs or the in-memory ring buffer.

Local verification (exact)

  • bun x tsc --noEmit -> PASSED (0 errors)
  • bun run test tests/request-history-index.test.ts -> 16/16 pass
    (1574 assertions) covering: empty/missing/corrupt/old-schema/partial-line/
    replacement/truncation/duplicate-replay/cursor-stability/invalid-cursor/
    page-bounds/rebuild-equivalence/filters/row-by-id
  • Focused regression suites (RI-01 tests, request-log, usage-log, combos,
    combo-management-api, codex-routing, codex-account-namespaces) -> 269/269
  • bun run privacy:scan -> passed
  • Full-suite baseline on clean upstream/dev runs out-of-band; result
    recorded in the stack ledger.

Notes for reviewers

  • Bun 1.3.14 quirks handled and regression-tested: named parameters in
    LIMIT/INSERT silently mis-bind (positional parameters used everywhere);
    unfinalized prepared statements keep the DB locked on Windows after close.

Summary by CodeRabbit

  • New Features

    • Added request-history browsing through the management API, including filtering, pagination, request lookup, and index status.
    • Added CLI commands to rebuild the request-history index and view its status, with optional JSON output.
    • Added reliable cursor-based pagination with validation and clear invalid-cursor errors.
    • Added automatic recovery when request-history data is missing, corrupted, truncated, or changed.
  • Bug Fixes

    • Prevented prompt content from appearing in route traces.
    • Improved handling of incomplete, invalid, and duplicate history entries.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 26a0720c-3a0b-4164-af44-b52690e10812

📥 Commits

Reviewing files that changed from the base of the PR and between 7a498ad and 2734800.

📒 Files selected for processing (1)
  • src/routing/history/indexer.ts

📝 Walkthrough

Walkthrough

The PR adds a rebuildable SQLite index over usage.jsonl, opaque cursor pagination, request-history management routes, CLI index commands, recovery handling, and tests for ingestion, validation, pagination, lookup, and privacy.

Changes

Request History

Layer / File(s) Summary
History schema and cursor contracts
src/routing/history/schema.ts, src/routing/history/cursor.ts, src/routing/history/indexer.ts
Defines the SQLite schema, metadata, database path, cursor encoding, cursor validation, public history contracts, and ingestion limits.
Index ingestion and recovery
src/routing/history/indexer.ts
Reads complete JSONL rows, batches duplicate-safe inserts, tracks source identity and progress, and rebuilds the index after corruption, truncation, replacement, or schema changes.
History query, management, and CLI surfaces
src/routing/history/indexer.ts, src/server/management/request-history-routes.ts, src/server/management-api.ts, src/cli/observe.ts
Adds filtered cursor pagination, row lookup, index status, management routes, route dispatch, and logs rebuild/status commands.
History validation and acceptance evidence
tests/request-history-index.test.ts, tests/route-decision-trace.test.ts, devlog/_plan/260804_router_intelligence/001_pr_stack_status.md
Adds coverage for indexing, recovery, pagination, filters, API validation, rebuild equivalence, cursor stability, and route-trace privacy. Updates stack acceptance records.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant ManagementAPI
  participant RequestHistoryRoutes
  participant RequestHistoryIndexer
  participant UsageLedger
  CLI->>RequestHistoryIndexer: rebuild index or read index status
  ManagementAPI->>RequestHistoryRoutes: request history query
  RequestHistoryRoutes->>RequestHistoryIndexer: validate filters and query cursor
  RequestHistoryIndexer->>UsageLedger: read canonical usage.jsonl tail
  RequestHistoryIndexer-->>RequestHistoryRoutes: page and index metadata
  RequestHistoryRoutes-->>ManagementAPI: history response
Loading

Possibly related PRs

Suggested reviewers: ingwannu, lidge-jun

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding indexed, cursor-paginated request history.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7efb6e8428

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/routing/history/indexer.ts Outdated
Comment thread src/server/management/request-history-routes.ts Outdated
Comment thread src/routing/history/indexer.ts
Comment thread src/routing/history/indexer.ts
Comment thread src/routing/history/indexer.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 24

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@devlog/_plan/260804_router_intelligence/000_master_plan.md`:
- Around line 393-398: Update the RI-10 acceptance criterion to require
documentation in all six locale files—en, de, ja, ko, ru, and zh—matching the
locale set defined earlier in the plan.
- Around line 252-256: Use one canonical rebuild timestamp key across the plan:
update the rebuild audit requirement near the indexer behavior description to
use the existing schema_meta key built_at_ms, and ensure the indexer, status
API, and tests reference that same field and millisecond unit instead of
rebuilt_at.
- Around line 186-187: Fix the RouteCandidateTrace schema formatting by
replacing the nested inline code spans with one valid Markdown code span or a
fenced code block. Preserve the full schema fields and exclusions constraint
while ensuring markdownlint MD038 passes.

In `@devlog/_plan/260804_router_intelligence/001_pr_stack_status.md`:
- Around line 40-45: Synchronize the RI-02 entry in the stack status table with
the completed implementation and verification recorded in the acceptance log:
replace the pending head SHA and PR fields with their finalized values and
update the status from “in progress” to the verified state. Keep the RI-02 base
reference unchanged and ensure the corresponding final commit and PR fields in
the acceptance log are updated consistently.

In `@src/router.ts`:
- Around line 538-557: Make trace construction optional in routeModel by adding
a trace opt-out option that preserves tracing by default. Only assign
route.routeDecision through buildRouteDecisionTrace when tracing is enabled,
while keeping routing behavior unchanged. Update the probe calls in
canDecryptUnreadableAgentTask to disable tracing since they only inspect the
selected provider.
- Around line 389-421: Update comboRouteCandidates to determine configured
providers with hasOwnProvider, matching the ownership check used by
routeModelInternal and getCombo. Only read provider.disabled after confirming
the provider is an own property, preserving the existing enabled, cooldown, and
candidate-exclusion behavior.

In `@src/routing/history/indexer.ts`:
- Around line 445-450: Coordinate rebuildRequestHistoryIndex across processes so
the CLI cannot drop and repopulate the index while the server serves requests;
prefer routing the rebuild through the existing management API via logs’
runtimeRequest flow, or otherwise add a config-directory advisory lock shared by
readers and rebuilders. Ensure rebuild-index preserves consistent
request-history reads during the full rebuild.
- Around line 500-515: Update the request-history refresh flow so the validated
database handle is returned alongside metadata: have refreshLocked return both
handle and meta on every exit, add internal openRequestHistoryIndexHandle
returning that RefreshResult, and keep openRequestHistoryIndex as a
metadata-only wrapper. Change queryRequestHistory and requestHistoryRowById to
use the returned handle instead of re-reading the mutable db singleton after
awaiting.
- Around line 181-197: Replace the whole-file buffering in readCompleteTail and
its ingestSourceTail integration with bounded chunk reads that preserve any
partial trailing line between chunks. Ensure chunks without a newline expand the
read window and never stall on an oversized line; ingest and commit each chunk
incrementally while advancing indexedOffset until the source is exhausted,
avoiding full-ledger buffers and split arrays.
- Around line 295-318: Update destroyAndRecreate to delete the database WAL and
SHM sidecars alongside path within the existing retry cleanup loop, before
creating the replacement database. Update indexDbPath to register both
corresponding -wal and -shm paths with recordOwnedConfigPath so ownership
cleanup removes them as well.
- Around line 523-528: Update the nextCursor construction in queryRequestHistory
to avoid re-parsing the final row’s row_json; select the indexed timestamp and
request_id ordering columns with each page row and use those values directly in
encodeHistoryCursor. Preserve the existing hasMore and non-empty page guard, and
ensure damaged JSON remains skipped by hydrateRow without causing pagination to
throw.
- Around line 487-498: Expose the implementation currently referenced as
normalizeUsageEntryForTest from usage/log.ts under the production name
normalizeUsageEntry, and update hydrateRow in indexer.ts to call that production
symbol. Retain normalizeUsageEntryForTest only as a deprecated compatibility
alias if existing tests require it, while preserving the current hydration and
validation behavior.
- Around line 128-133: Update sourceIdentityMatches to compare only
revision.path, revision.dev, revision.ino, and revision.birthtimeMs against the
stored identity fields, while leaving size and mtime checks to the
offset/truncation logic. In openIndexDb, initialize those identity fields in the
fresh-database branch using the existing metadata flow so the first refresh does
not rebuild unnecessarily.

In `@src/routing/history/schema.ts`:
- Around line 70-72: Update indexDbPath in the history indexer to import and
call historyIndexPath instead of constructing the database path inline with
HISTORY_DB_FILENAME. Preserve the existing directory input while reusing
historyIndexPath’s trailing-separator normalization.

In `@src/routing/trace.ts`:
- Around line 322-338: Update enforceByteBudget to measure serialized trace size
in UTF-8 bytes rather than UTF-16 code units. Replace both serialized.length
checks with TextEncoder-based encoded byte-length checks, preserving the
existing truncation and candidate-reduction flow.
- Around line 474-478: Update the evidence object construction around
parseCapability, parseHealth, parseQuota, parseCost, and parseScore so each
parser result is computed once per candidate, stored in a local binding, and
reused for both the presence check and property value. Preserve the existing
omission behavior for falsy or absent parsed results while eliminating the
duplicate parser calls.
- Around line 275-279: Replace the requirements-overflow assignment in the
requirements normalization block so it sets a dedicated truncated.requirements
flag instead of truncated.candidates. Add the corresponding flag to the
truncated shape, and update normalizeRouteDecisionTrace to preserve it during
defensive parsing and persisted trace round trips while leaving candidate
truncation behavior unchanged.

In `@src/server/management/request-history-routes.ts`:
- Around line 92-96: Update the GET request-history route around requestId
decoding to catch malformed URI decoding and treat it as an unknown request,
returning the existing 404 response instead of propagating the URIError.
Preserve the current empty-ID and slash validation, and add a regression test in
the request-history index tests for GET /api/request-history/% asserting a 404
response.
- Around line 23-27: Update parseOptionalInt to distinguish absent parameters
from present-but-invalid values, including treating trimmed empty strings as
invalid, then update each request-history validation path to return a stable 400
for the invalid marker before applying defaults or filters. Preserve undefined
only for genuinely absent parameters, and add regression coverage in the
existing validation tests for invalid status, limit, and from query values.

In `@src/server/request-log.ts`:
- Around line 255-267: Update normalizeRouteDecisionTraceForLog to return the
normalized result directly and omit routeDecision when
normalizeRouteDecisionTrace rejects the persisted trace, rather than falling
back to the original entry. Align the request-log hydration and DTO mapping flow
with the existing rejection behavior in usage logging, while preserving valid
normalized traces.

In `@src/server/responses/core.ts`:
- Line 856: Update handleComboResponses so promoting child logs via
Object.assign does not overwrite the parent alias-level routeDecision with the
child’s explicit-provider trace. Preserve the existing combo routeDecision, or
rebuild it as a combo trace from the selected target, on both successful and
exhausted paths. Add persistence coverage for both outcomes, verifying
route_kind and decision_json retain the combo trace.

In `@tests/request-history-index.test.ts`:
- Around line 247-259: Extend the existing invalid-filter test after the
badLimit and badStatus requests to assert error codes invalid_limit and
invalid_status, not only HTTP 400. Add a from=2&to=1 request and assert HTTP 400
with error code invalid_range, preserving the existing invalid_cursor assertion.
- Around line 270-280: Strengthen the test around rebuildRequestHistoryIndex by
reading the canonical JSONL ledger entries after appendUsageEntry, ordering them
according to the defined request-history order, and comparing the rebuilt
queryRequestHistory rows against those canonical entries rather than comparing
two identical rebuilt projections. Keep the indexedRows count assertion and
place the focused regression coverage alongside the existing request-history
tests.

In `@tests/route-decision-trace.test.ts`:
- Around line 125-141: Update the test “combo candidates are capped and the
selected candidate survives truncation” to select a candidate beyond
MAX_TRACE_CANDIDATES by making the early targets ineligible or invoking
buildRouteDecisionTrace directly. Assert the truncated trace retains the
selected final candidate and that selected.candidateIndex is adjusted to its
final index.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6dade0d3-9678-4e4c-9bd9-9e9602eb8b5c

📥 Commits

Reviewing files that changed from the base of the PR and between e44d234 and 7efb6e8.

📒 Files selected for processing (19)
  • devlog/_plan/260804_router_intelligence/000_master_plan.md
  • devlog/_plan/260804_router_intelligence/001_pr_stack_status.md
  • src/cli/observe.ts
  • src/router.ts
  • src/routing/history/cursor.ts
  • src/routing/history/indexer.ts
  • src/routing/history/schema.ts
  • src/routing/trace.ts
  • src/server/chat-completions.ts
  • src/server/claude-messages.ts
  • src/server/management-api.ts
  • src/server/management/request-history-routes.ts
  • src/server/request-log.ts
  • src/server/responses/compact.ts
  • src/server/responses/core.ts
  • src/server/search.ts
  • src/usage/log.ts
  • tests/request-history-index.test.ts
  • tests/route-decision-trace.test.ts

Comment thread devlog/_plan/260804_router_intelligence/000_master_plan.md
Comment thread devlog/_plan/260804_router_intelligence/000_master_plan.md
Comment thread devlog/_plan/260804_router_intelligence/000_master_plan.md Outdated
Comment thread devlog/_plan/260804_router_intelligence/001_pr_stack_status.md
Comment thread src/router.ts Outdated
Comment thread src/server/request-log.ts Outdated
Comment thread src/server/responses/core.ts
Comment thread tests/request-history-index.test.ts
Comment thread tests/request-history-index.test.ts
Comment thread tests/route-decision-trace.test.ts
@Wibias
Wibias force-pushed the feat/ri-02-request-history-index branch from 7efb6e8 to 85d3b24 Compare August 4, 2026 21:01
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (8)
src/routing/history/indexer.ts (5)

295-318: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

destroyAndRecreate still leaves the WAL sidecars on disk.

Line 312 enables PRAGMA journal_mode = WAL, so SQLite maintains routing-history.sqlite-wal and routing-history.sqlite-shm. Line 304 unlinks only path. A stale -wal from the discarded database survives next to the replacement database, and indexDbPath (Lines 84-88) registers only the main file with recordOwnedConfigPath, so config-ownership cleanup never removes the sidecars.

🐛 Proposed fix
   const fresh = new Database(path, { create: true });
+  // WAL mode keeps `-wal`/`-shm` sidecars; a stale WAL must not outlive its database.
+  for (const suffix of ["-wal", "-shm"]) {
+    try { unlinkSync(`${path}${suffix}`); } catch { /* absent */ }
+  }

Move the sidecar removal above new Database(path, ...), and register both sidecar paths in indexDbPath.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routing/history/indexer.ts` around lines 295 - 318, Update
destroyAndRecreate to remove the SQLite WAL and SHM sidecar files, alongside the
main database path, before constructing the replacement Database. Update
indexDbPath to register both sidecar paths with recordOwnedConfigPath so
ownership cleanup removes them as well.

128-133: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

sourceIdentityMatches still forces a full rebuild after every normal append.

refreshLocked calls ensureSchemaAndIdentity (Line 398) before it reaches the incremental offset check. ingestSourceTail stores the size and mtime observed at ingest time (Lines 257-258). One new ledger row changes revision.size, so Lines 131-132 report a mismatch, ensureSchemaAndIdentity calls destroyAndRecreate (Line 364), and refreshLocked runs fullRebuild. The incremental branch at Lines 417-419 therefore never runs after an append, and every GET /api/request-history reingests the whole ledger.

The check also ignores sourcePath, sourceDev, sourceIno, and sourceBirthtimeMs, although recordSourceMeta stores them at Lines 277-284. A replacement file with the same size and mtime passes the check and causes tail ingestion from a stale offset.

Compare identity fields only, and leave size and mtime to the offset and truncation logic at Lines 411-419.

🐛 Proposed fix
 function sourceIdentityMatches(dbHandle: Database, revision: UsageLogRevision | null): boolean {
-  const stored = readIndexedMeta(dbHandle);
-  if (revision === null) return stored.sourceSize === 0;
-  return stored.sourceSize === Number(revision.size)
-    && stored.sourceMtimeMs === Number(revision.mtimeMs);
+  if (revision === null) return readIndexedMeta(dbHandle).sourceSize === 0;
+  return metaValue(dbHandle, HISTORY_META_KEYS.sourcePath) === revision.path
+    && metaValue(dbHandle, HISTORY_META_KEYS.sourceDev) === String(revision.dev)
+    && metaValue(dbHandle, HISTORY_META_KEYS.sourceIno) === String(revision.ino)
+    && metaValue(dbHandle, HISTORY_META_KEYS.sourceBirthtimeMs) === String(revision.birthtimeMs);
 }

Also record the identity fields in the fresh-database branch of openIndexDb (Lines 329-337) so the first refresh does not rebuild without cause.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routing/history/indexer.ts` around lines 128 - 133, Update
sourceIdentityMatches to compare only source identity fields—sourcePath,
sourceDev, sourceIno, and sourceBirthtimeMs—while leaving sourceSize and
sourceMtimeMs to the offset/truncation logic in refreshLocked. Ensure the
fresh-database branch of openIndexDb records these identity fields via the
existing metadata mechanism so the initial refresh does not trigger an
unnecessary rebuild.

523-528: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The nextCursor path still re-parses row_json without a guard and reads non-indexed values.

hydrateRow wraps JSON.parse in try/catch and skips a damaged row (Lines 489-497). Line 526 parses the same row_json again with no guard. If the last row of a page holds invalid JSON, JSON.parse throws SyntaxError, queryRequestHistory does not catch it, and handleRequestHistoryRoutes catches only InvalidCursorError, so the client receives a 500 instead of a page.

Second defect in the same lines: parsed.timestamp and parsed.requestId come from the JSON blob, but pagination orders and filters on the indexed timestamp and request_id columns. If those values ever disagree, the cursor points outside the keyset window and the client skips or repeats rows.

Select the ordering columns and use them directly.

🐛 Proposed fix
   const rows = handle.query(
-    `SELECT row_json FROM requests${whereSql} ORDER BY timestamp DESC, request_id DESC LIMIT ?`,
-  ).all(...values, limit + 1) as Array<{ row_json: string }>;
+    `SELECT row_json, timestamp, request_id FROM requests${whereSql} ORDER BY timestamp DESC, request_id DESC LIMIT ?`,
+  ).all(...values, limit + 1) as Array<{ row_json: string; timestamp: number; request_id: string }>;
   if (hasMore && pageRows.length > 0) {
     const last = pageRows[pageRows.length - 1]!;
-    const parsed = JSON.parse(last.row_json) as PersistedUsageEntry;
-    nextCursor = encodeHistoryCursor({ t: parsed.timestamp, i: parsed.requestId });
+    nextCursor = encodeHistoryCursor({ t: last.timestamp, i: last.request_id });
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routing/history/indexer.ts` around lines 523 - 528, Update the nextCursor
logic in queryRequestHistory to avoid re-parsing pageRows[last].row_json and
instead use the row’s indexed timestamp and request_id values directly when
calling encodeHistoryCursor. Preserve the existing hasMore and non-empty page
guard so malformed JSON skipped by hydrateRow cannot cause pagination to throw.

181-197: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

readCompleteTail still buffers the whole ledger synchronously on the request thread.

On a full rebuild ingestSourceTail passes fromOffset = 0 (Line 389), so length at Line 183 equals the whole usage.jsonl size. Peak memory holds three copies: the Buffer (Line 184), the string from .toString("utf-8") (Line 194), and the line array from text.split(/\r?\n/) (Line 217). queryRequestHistory awaits this path, so a single GET /api/request-history performs a synchronous whole-file read plus every insert on the server request thread. Combined with the rebuild-on-append defect at Lines 128-133, this cost is paid on each query.

Read and ingest in bounded chunks, and carry the partial trailing line across chunks so peak memory stays fixed. A chunk that contains no newline must grow the read window, otherwise a line longer than the chunk size stalls progress.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routing/history/indexer.ts` around lines 181 - 197, Refactor
readCompleteTail and the ingestSourceTail path to read the ledger in bounded
chunks instead of allocating the full file and splitting it at once. Preserve
partial trailing-line data across chunks, emit complete lines for ingestion, and
ensure newline-free chunks expand or otherwise advance the read window so lines
longer than the chunk size do not stall. Update the request-history flow to use
this incremental ingestion while preserving offsets and existing rebuild
behavior.

500-515: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

db! is still read after an await, so a concurrent close dereferences null.

openRequestHistoryIndex returns meta only; it does not hand back the handle. Line 514 re-reads the module singleton db and asserts non-null. closeRequestHistoryIndex (Lines 437-443) sets db = null. Between the await at Line 505 and the read at Line 514, a shutdown path or a test teardown can null it, and Line 515 throws inside queryRows. handleRequestHistoryRoutes catches only InvalidCursorError, so the caller receives a 500. requestHistoryRowById has the same defect at Line 534.

Fix the root cause: return the validated handle from the refresh.

🐛 Proposed fix
+interface RefreshResult { handle: Database; meta: RequestHistoryIndexMeta }
+
+async function openRequestHistoryIndexHandle(): Promise<RefreshResult> { /* single-flight refresh returning { handle, meta } */ }
-  const meta = await openRequestHistoryIndex();
+  const { handle, meta } = await openRequestHistoryIndexHandle();
   ...
-  const handle = db!;
   const { rows, total } = queryRows(handle, filters, cursor, limit);

Keep openRequestHistoryIndex as a meta-only wrapper for the existing public contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routing/history/indexer.ts` around lines 500 - 515, Update the
request-history index refresh flow so it returns the validated database handle
alongside its metadata, then use that returned handle in queryRequestHistory and
requestHistoryRowById instead of re-reading the nullable db singleton after
await. Preserve openRequestHistoryIndex as the existing metadata-only public
wrapper, while ensuring both callers use the handle obtained during refresh.
src/routing/history/schema.ts (1)

70-72: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

historyIndexPath is still bypassed by the indexer.

historyIndexPath normalizes trailing separators before it appends HISTORY_DB_FILENAME. indexDbPath in src/routing/history/indexer.ts (Lines 84-88) still builds ${dir}/${HISTORY_DB_FILENAME} inline, twice, and skips that normalization. Two constructions of the same path can drift, and recordOwnedConfigPath then records a path that other callers do not use.

Import historyIndexPath in the indexer and call it from indexDbPath.

♻️ Proposed change in src/routing/history/indexer.ts
 function indexDbPath(): string {
   const dir = getConfigDir();
-  recordOwnedConfigPath(dir, `${dir}/${HISTORY_DB_FILENAME}`);
-  return `${dir}/${HISTORY_DB_FILENAME}`;
+  const path = historyIndexPath(dir);
+  recordOwnedConfigPath(dir, path);
+  return path;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routing/history/schema.ts` around lines 70 - 72, Update indexDbPath in
the history indexer to import and use historyIndexPath for both index-path
constructions instead of interpolating HISTORY_DB_FILENAME directly. Preserve
the existing directory inputs and recordOwnedConfigPath behavior while ensuring
all callers use the normalized path helper.
src/server/management/request-history-routes.ts (2)

92-96: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

decodeURIComponent still throws on a malformed percent sequence, producing a 500.

Line 93 decodes the raw path remainder with no guard. GET /api/request-history/% or /api/request-history/%zz makes decodeURIComponent throw URIError: URI malformed. The try/catch at Line 84 covers only the collection route. handleManagementAPI in src/server/management-api.ts (Lines 152-166) handles only managementBodyTooLargeResponse, OAuthMutationBusyError, and CatalogGatherBusyError, and rethrows everything else. A malformed identifier should return 404, not 500.

🐛 Proposed fix
-    const requestId = decodeURIComponent(url.pathname.slice("/api/request-history/".length));
-    if (!requestId || requestId.includes("/")) {
+    const rawId = url.pathname.slice("/api/request-history/".length);
+    let requestId: string;
+    try {
+      requestId = decodeURIComponent(rawId);
+    } catch {
+      // Malformed percent-encoding is an unknown request, not a server fault.
+      return jsonResponse({ error: { code: "not_found", message: "unknown request" } }, 404, req, config);
+    }
+    if (!requestId || requestId.includes("/")) {
       return jsonResponse({ error: { code: "not_found", message: "unknown request" } }, 404, req, config);
     }

Add a regression test for GET /api/request-history/% in tests/request-history-index.test.ts asserting 404.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/management/request-history-routes.ts` around lines 92 - 96, Guard
the requestId decoding in the GET request-history route so malformed
percent-encoded path segments are treated as unknown requests and return the
existing 404 response instead of throwing. Update the route logic around
requestId and add a regression test in the request-history index tests for GET
/api/request-history/% asserting status 404.

23-27: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

parseOptionalInt still maps invalid input to undefined, so bad filters are dropped instead of rejected.

The function cannot distinguish "absent" from "present but not an integer". Both return undefined, and every caller then treats the parameter as absent.

Reachable cases through GET /api/request-history:

  • ?status=abcundefined. The guard at Line 35 is skipped, queryRequestHistory receives no status filter, and the response is unfiltered history with a 200.
  • ?from=abc&to=abc → both undefined. The range check at Line 40 is skipped and the time window is dropped silently.
  • ?limit=abcundefined. The guard at Line 45 is skipped and the default page size applies.

The PR objectives state that invalid filters return a stable 400. This code returns 200 with the wrong result set. Empty strings behave inconsistently too, because Number("") is 0: ?status= returns 400, but ?from= applies a real timestamp >= 0 filter.

🐛 Proposed fix
-function parseOptionalInt(raw: string | null): number | undefined {
-  if (raw === null) return undefined;
-  const value = Number(raw.trim());
-  return Number.isInteger(value) ? value : undefined;
-}
+const INVALID = Symbol("invalid");
+
+/** `undefined` means absent; `INVALID` means present but not an integer. */
+function parseOptionalInt(raw: string | null): number | undefined | typeof INVALID {
+  if (raw === null) return undefined;
+  const trimmed = raw.trim();
+  if (trimmed.length === 0) return INVALID;
+  const value = Number(trimmed);
+  return Number.isInteger(value) ? value : INVALID;
+}
-    if (status !== undefined && (status < 100 || status > 599)) {
+    if (status === INVALID || (status !== undefined && (status < 100 || status > 599))) {

Reject INVALID for from, to, and limit in the same way. Line 44 can then call parseOptionalInt(url.searchParams.get("limit")) directly, because the function already handles null.

Add regression tests for ?status=abc, ?limit=abc, and ?from=abc next to the existing validation tests in tests/request-history-index.test.ts, asserting 400. As per path instructions, a behavior change in src/ should come with a focused regression test near the existing tests for that subsystem.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/management/request-history-routes.ts` around lines 23 - 27, Update
parseOptionalInt to distinguish absent, valid, and present-but-invalid values,
returning the established INVALID result for non-integer or empty-string input
while preserving undefined for null. Update the GET /api/request-history
validation for status, from, to, and limit to reject INVALID with a stable 400,
and pass limit directly through parseOptionalInt. Add focused regression tests
in the existing request-history validation tests for invalid status, limit, and
from query parameters, each asserting 400.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/cli/observe.ts`:
- Around line 84-90: Update rebuildIndex and indexStatus to call the running
server’s management API through runtimeRequest, matching the logs handler,
instead of importing and invoking the history indexer directly. Preserve their
existing argument parsing and output behavior while passing deps through the
request path; this ensures the server owns index writes. If standalone execution
is intentionally required, instead remove the unused deps parameters and
document that constraint.

In `@src/routing/history/indexer.ts`:
- Around line 452-485: Rename the `queryRows` return property and its local
usage from `total` to `fetched`, including the return object and the
`queryRequestHistory` consumer, while preserving its meaning as the number of
rows retrieved by the `limit + 1` probe used for `hasMore`.

In `@tests/request-history-index.test.ts`:
- Around line 282-291: Add a focused pagination test near “cursor stays stable
while appends arrive between pages” that seeds more rows than one page with
identical timestamps, pages through them using a small limit and successive
nextCursor values, and asserts every expected requestId appears exactly once
without skips or duplicates.
- Around line 199-213: Expand the filters test around queryRequestHistory to add
distinct fixture rows and focused assertions for model, requestedModel,
inboundProtocol, apiKeyId, profileId, and fallback, ensuring each query selects
only its intended row. Keep the existing provider, status, conversationId,
surface, and date-range checks, and use the request-history filter fields
forwarded by the route.

---

Duplicate comments:
In `@src/routing/history/indexer.ts`:
- Around line 295-318: Update destroyAndRecreate to remove the SQLite WAL and
SHM sidecar files, alongside the main database path, before constructing the
replacement Database. Update indexDbPath to register both sidecar paths with
recordOwnedConfigPath so ownership cleanup removes them as well.
- Around line 128-133: Update sourceIdentityMatches to compare only source
identity fields—sourcePath, sourceDev, sourceIno, and sourceBirthtimeMs—while
leaving sourceSize and sourceMtimeMs to the offset/truncation logic in
refreshLocked. Ensure the fresh-database branch of openIndexDb records these
identity fields via the existing metadata mechanism so the initial refresh does
not trigger an unnecessary rebuild.
- Around line 523-528: Update the nextCursor logic in queryRequestHistory to
avoid re-parsing pageRows[last].row_json and instead use the row’s indexed
timestamp and request_id values directly when calling encodeHistoryCursor.
Preserve the existing hasMore and non-empty page guard so malformed JSON skipped
by hydrateRow cannot cause pagination to throw.
- Around line 181-197: Refactor readCompleteTail and the ingestSourceTail path
to read the ledger in bounded chunks instead of allocating the full file and
splitting it at once. Preserve partial trailing-line data across chunks, emit
complete lines for ingestion, and ensure newline-free chunks expand or otherwise
advance the read window so lines longer than the chunk size do not stall. Update
the request-history flow to use this incremental ingestion while preserving
offsets and existing rebuild behavior.
- Around line 500-515: Update the request-history index refresh flow so it
returns the validated database handle alongside its metadata, then use that
returned handle in queryRequestHistory and requestHistoryRowById instead of
re-reading the nullable db singleton after await. Preserve
openRequestHistoryIndex as the existing metadata-only public wrapper, while
ensuring both callers use the handle obtained during refresh.

In `@src/routing/history/schema.ts`:
- Around line 70-72: Update indexDbPath in the history indexer to import and use
historyIndexPath for both index-path constructions instead of interpolating
HISTORY_DB_FILENAME directly. Preserve the existing directory inputs and
recordOwnedConfigPath behavior while ensuring all callers use the normalized
path helper.

In `@src/server/management/request-history-routes.ts`:
- Around line 92-96: Guard the requestId decoding in the GET request-history
route so malformed percent-encoded path segments are treated as unknown requests
and return the existing 404 response instead of throwing. Update the route logic
around requestId and add a regression test in the request-history index tests
for GET /api/request-history/% asserting status 404.
- Around line 23-27: Update parseOptionalInt to distinguish absent, valid, and
present-but-invalid values, returning the established INVALID result for
non-integer or empty-string input while preserving undefined for null. Update
the GET /api/request-history validation for status, from, to, and limit to
reject INVALID with a stable 400, and pass limit directly through
parseOptionalInt. Add focused regression tests in the existing request-history
validation tests for invalid status, limit, and from query parameters, each
asserting 400.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 40b03b6d-416d-471f-bc7e-b70de4f735bc

📥 Commits

Reviewing files that changed from the base of the PR and between 34d21b1 and 85d3b24.

📒 Files selected for processing (9)
  • devlog/_plan/260804_router_intelligence/001_pr_stack_status.md
  • src/cli/observe.ts
  • src/routing/history/cursor.ts
  • src/routing/history/indexer.ts
  • src/routing/history/schema.ts
  • src/server/management-api.ts
  • src/server/management/request-history-routes.ts
  • tests/request-history-index.test.ts
  • tests/route-decision-trace.test.ts

Comment thread src/cli/observe.ts
Comment thread src/routing/history/indexer.ts
Comment thread tests/request-history-index.test.ts
Comment thread tests/request-history-index.test.ts
@Wibias

Wibias commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

[GD] Verdict: approve-comment

TLDR

  • PR: #1004 — feat(control-plane): add indexed cursor-paginated request history
  • Head: 27348005 on dev (mergeStateStatus: MERGEABLE, mergeable_state: unstable pending CI)
  • Decision: Useful and ready to merge once required checks on 27348005 finish green.
  • Usefulness: Delivers RI-02 rebuildable SQLite index over canonical usage.jsonl with filters, cursor API, and ocx logs commands.
  • Bugs: none blocking — inode-identity rebuild bug fixed; filter validation, cursor safety, URI decode hardened; streaming tail read deferred as P2 perf.
  • Security: none — local config-dir index, parameterized SQL, no new secret surface; privacy:scan green on reviewed heads.
  • Spec / standards: docs-site follow-up recommended for new API/CLI (not blocking this PR).
  • Reviews: Codex + CodeRabbit threads triaged; 33/33 resolved with [GD] replies; RI-01-only paths marked out-of-scope.
  • Base / CI: rebased onto merged #1003 (dev @ 34d21b1b); 7a498ad6 fully green; 27348005 simplify rename re-triggered required checks (pending).
  • Gate: none (not draft/WIP); wait for CI on latest head before merge.
  • Owner actions (foreign PR): none — stack sync, review fixes, bot triage, and trivial simplify already applied on fork.
  • Bottom line: Approve with comment. RI-02 is merge-ready after CI on 27348005 completes.
Full verdict

Semantic propagation

  • Concepts audited: request history index, cursor pagination, filters, routeDecision round-trip
  • Authoritative sources: usage.jsonl
  • Producers and consumers checked: indexer → SQLite → /api/request-historyrequestLogDto
  • Axis verdict: pass

Bugs / correctness

  • Findings: none blocking after fixes on 7a498ad6 / 27348005
  • Fixed this session: inode identity, query validation, cursor columns, requireDb(), schema path dedup; simplify rename totalfetched

Simplify

Applied trivial rename; declined streaming rebuild, cross-process lock, and shared normalizeUsageEntry rename.

@Wibias
Wibias merged commit 2a72aa4 into lidge-jun:dev Aug 4, 2026
20 checks passed
@Wibias

Wibias commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

[GD] Merged — why it helps: RI-02 adds a rebuildable SQLite projection over usage.jsonl with cursor-paginated /api/request-history and CLI index commands (ocx logs rebuild-index, index-status). This keeps usage.jsonl canonical while enabling efficient history queries without mutating the ledger.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant