Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/calm-runs-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": minor
---

Add keyset pagination to progress-run listing through `ListRunsOptions.before` and the `beforeStartedAt`/`beforeId` HTTP query parameters.
28 changes: 28 additions & 0 deletions packages/core/src/progress/progress.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,34 @@ describe("progress routes", () => {
});
});

it("forwards a complete keyset cursor for subsequent run pages", async () => {
const handler = createProgressHandler() as any;
const event = createEvent(
"/?limit=200&active=true&beforeStartedAt=2026-07-30T12%3A00%3A00.000Z&beforeId=run-0200",
);

await handler(event);

expect(mockListRuns).toHaveBeenCalledWith("boni@local", {
activeOnly: true,
before: {
startedAt: "2026-07-30T12:00:00.000Z",
id: "run-0200",
},
event,
limit: 200,
});
});

it("rejects incomplete keyset cursors instead of repeating the first page", async () => {
const handler = createProgressHandler() as any;

await expect(
handler(createEvent("/?beforeStartedAt=2026-07-30T12%3A00%3A00.000Z")),
).rejects.toMatchObject({ statusCode: 400 });
expect(mockListRuns).not.toHaveBeenCalled();
});

it("short-circuits OPTIONS before auth", async () => {
const handler = createProgressHandler() as any;
mockGetSession.mockRejectedValue(new Error("should not authenticate"));
Expand Down
30 changes: 29 additions & 1 deletion packages/core/src/progress/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* Mounted under `/_agent-native/runs/*` by `core-routes-plugin`.
*
* GET /_agent-native/runs?active=true&limit=50
* GET /_agent-native/runs?active=true&limit=50&beforeStartedAt=<iso>&beforeId=<id>
* GET /_agent-native/runs/:id
* DELETE /_agent-native/runs/:id
*
Expand All @@ -30,6 +30,22 @@ function parseLimit(value: unknown, fallback = 50): number {
return Math.min(Math.floor(n), 200);
}

function parseBefore(
startedAt: unknown,
id: unknown,
): { startedAt: string; id: string } | undefined {
if (startedAt == null && id == null) return undefined;
if (
typeof startedAt !== "string" ||
!Number.isFinite(Date.parse(startedAt)) ||
Comment on lines +38 to +40

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.

🟡 Reject normalized invalid ISO cursor dates

Date.parse accepts malformed calendar dates such as 2026-02-30T00:00:00.000Z and normalizes them to a different finite timestamp. The route therefore accepts an invalid cursor and queries from the normalized date instead of returning the documented 400, which can silently skip or repeat rows. Require the documented ISO-8601 form and validate a round trip (or use a strict date validator), then add a test for an out-of-range calendar date.

Additional Info
Found by 1 of 2 code-review agents; independently confirmed that JavaScript Date.parse normalizes out-of-range calendar dates.

Fix in Builder

typeof id !== "string" ||
id.length === 0
) {
throw new Error("Invalid progress run cursor");
}
return { startedAt, id };
}

async function resolveOwner(event: H3Event): Promise<string> {
const session = await getSession(event).catch(() => null);
if (!session?.email) {
Expand All @@ -56,8 +72,20 @@ export function createProgressHandler() {
// GET / — list
if (method === "GET" && parts.length === 0) {
const q = getQuery(event);
let before: ReturnType<typeof parseBefore>;
try {
before = parseBefore(q.beforeStartedAt, q.beforeId);
} catch {
const { createError } = await import("h3");
throw createError({
statusCode: 400,
statusMessage:
"beforeStartedAt and beforeId must form a valid run cursor",
});
}
return listRuns(owner, {
activeOnly: q.active === "true" || q.active === "1",
...(before ? { before } : {}),
limit: parseLimit(q.limit),
event,
});
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/progress/store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,38 @@ describe("progress store", () => {
expect(call.args).toEqual(["alice@example.com", 50]);
});

it("uses a deterministic keyset cursor to retrieve rows after the first 200", async () => {
await listRuns("alice@example.com", {
activeOnly: true,
limit: 200,
before: {
startedAt: "2026-07-30T12:00:00.000Z",
id: "run-0200",
},
});

const call = lastSelect();
expect(call.sql).toMatch(
/started_at < \? OR \(started_at = \? AND id < \?\)/,
);
expect(call.sql).toMatch(/ORDER BY started_at DESC, id DESC LIMIT \?/);
expect(call.args).toEqual([
"alice@example.com",
Date.parse("2026-07-30T12:00:00.000Z"),
Date.parse("2026-07-30T12:00:00.000Z"),
"run-0200",
200,
]);
});

it("rejects an invalid keyset cursor instead of repeating the first page", async () => {
await expect(
listRuns("alice@example.com", {
before: { startedAt: "not-a-timestamp", id: "run-0200" },
}),
).rejects.toThrow(/before\.startedAt/);
});

it("marks stale running rows cancelled before listing active runs", async () => {
const now = Date.UTC(2026, 4, 8, 16, 0, 0);
vi.spyOn(Date, "now").mockReturnValue(now);
Expand Down
19 changes: 15 additions & 4 deletions packages/core/src/progress/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ async function ensureTable(): Promise<void> {
// avoid ACCESS EXCLUSIVE lock contention in fresh background-worker processes.
await ensureTableExists("progress_runs", createSql);
await ensureIndexExists(
"idx_progress_runs_owner_status",
`CREATE INDEX IF NOT EXISTS idx_progress_runs_owner_status ON progress_runs (owner, status, started_at)`,
"idx_progress_runs_owner_status_started_id",
`CREATE INDEX IF NOT EXISTS idx_progress_runs_owner_status_started_id ON progress_runs (owner, status, started_at DESC, id DESC)`,
Comment on lines 65 to +67

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.

🟡 Add an ordering index for unfiltered pagination

listRuns defaults to activeOnly: false, so the public list route and manage-progress listing execute WHERE owner = ? ... ORDER BY started_at DESC, id DESC. Because status is unconstrained, (owner, status, started_at, id) cannot provide that ordering; the database must scan/sort the owner's rows on default and subsequent cursor pages. Add a compatible (owner, started_at DESC, id DESC) index for unfiltered listings while retaining the status-prefixed index for activeOnly, and cover the unfiltered path with a query-plan or integration test.

Additional Info
Found by 1 of 2 code-review agents; verified against listRuns default activeOnly behavior and the changed index definition.

Fix in Builder

);
return;
}
Expand All @@ -78,7 +78,7 @@ async function ensureTable(): Promise<void> {
await client.execute(createSql);
try {
await client.execute(
`CREATE INDEX IF NOT EXISTS idx_progress_runs_owner_status ON progress_runs (owner, status, started_at)`,
`CREATE INDEX IF NOT EXISTS idx_progress_runs_owner_status_started_id ON progress_runs (owner, status, started_at DESC, id DESC)`,
);
} catch {
// Index already exists or the dialect rejected a duplicate.
Expand Down Expand Up @@ -331,9 +331,20 @@ export async function listRuns(
let where = `owner = ?`;
const args: Array<string | number> = [owner];
if (options.activeOnly) where += ` AND status = 'running'`;
if (options.before) {
const beforeStartedAt = Date.parse(options.before.startedAt);
if (!Number.isFinite(beforeStartedAt)) {
throw new TypeError("before.startedAt must be a valid timestamp");
}
if (!options.before.id) {
throw new TypeError("before.id must be a non-empty string");
}
where += ` AND (started_at < ? OR (started_at = ? AND id < ?))`;
args.push(beforeStartedAt, beforeStartedAt, options.before.id);
}
args.push(limit);
const { rows } = await client.execute({
sql: `SELECT * FROM progress_runs WHERE ${where} ORDER BY started_at DESC LIMIT ?`,
sql: `SELECT * FROM progress_runs WHERE ${where} ORDER BY started_at DESC, id DESC LIMIT ?`,
args,
});
return rows.map((r) => parseRow(r as Record<string, unknown>));
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/progress/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export interface ListRunsOptions {
activeOnly?: boolean;
/** Max rows. Default 50. */
limit?: number;
/**
* Return rows strictly older than this run in `(startedAt, id)` order.
* Pass the final row from the previous page to continue listing.
*/
before?: Pick<AgentRun, "startedAt" | "id">;
/** Optional request event for producers that need to self-dispatch work. */
event?: unknown;
}
Loading