mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 00:47:51 +00:00
fix: keep history cursor stable while backfill is incomplete
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { deriveRuntimeFreshness, probeOpenClawLocalState } from "@/lib/controlplane/degraded-read";
|
||||
import { selectAgentHistoryEntries } from "@/lib/controlplane/read-model";
|
||||
import { getControlPlaneRuntime, isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime";
|
||||
import { bootstrapDomainRuntime } from "@/lib/controlplane/runtime-route-bootstrap";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 1000;
|
||||
const BACKFILL_BATCH_LIMIT = 500;
|
||||
const MAX_BACKFILL_BATCHES_PER_REQUEST = 2;
|
||||
|
||||
const resolveLimit = (raw: string | null): number => {
|
||||
if (!raw) return DEFAULT_LIMIT;
|
||||
@@ -17,11 +18,20 @@ const resolveLimit = (raw: string | null): number => {
|
||||
return Math.min(Math.floor(parsed), MAX_LIMIT);
|
||||
};
|
||||
|
||||
const resolveBeforeOutboxId = (raw: string | null, fallback: number): number => {
|
||||
if (!raw) return fallback;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
if (parsed <= 0) return fallback;
|
||||
return Math.min(Math.floor(parsed), fallback);
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: Promise<{ agentId: string }> }
|
||||
) {
|
||||
if (!isStudioDomainApiModeEnabled()) {
|
||||
const bootstrap = await bootstrapDomainRuntime();
|
||||
if (bootstrap.kind === "mode-disabled") {
|
||||
return NextResponse.json({ enabled: false, error: "domain_api_mode_disabled" }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -31,26 +41,61 @@ export async function GET(
|
||||
return NextResponse.json({ error: "agentId is required." }, { status: 400 });
|
||||
}
|
||||
|
||||
const controlPlane = getControlPlaneRuntime();
|
||||
let startError: string | null = null;
|
||||
try {
|
||||
await controlPlane.ensureStarted();
|
||||
} catch (err) {
|
||||
startError = err instanceof Error ? err.message : "controlplane_start_failed";
|
||||
if (bootstrap.kind === "runtime-init-failed") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
enabled: true,
|
||||
error: bootstrap.message,
|
||||
code: "CONTROLPLANE_RUNTIME_INIT_FAILED",
|
||||
reason: "runtime_init_failed",
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
const controlPlane = bootstrap.runtime;
|
||||
const startError = bootstrap.kind === "start-failed" ? bootstrap.message : null;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = resolveLimit(url.searchParams.get("limit"));
|
||||
const snapshot = controlPlane.snapshot();
|
||||
const beforeOutboxId = resolveBeforeOutboxId(
|
||||
url.searchParams.get("beforeOutboxId"),
|
||||
snapshot.outboxHead + 1
|
||||
);
|
||||
const probe = snapshot.status === "connected" ? null : await probeOpenClawLocalState();
|
||||
const allEntries = controlPlane.eventsAfter(0, MAX_LIMIT * 5);
|
||||
const entries = selectAgentHistoryEntries(allEntries, normalizedAgentId, limit);
|
||||
let results = controlPlane.eventsBeforeForAgent(normalizedAgentId, beforeOutboxId, limit + 1);
|
||||
let backfillIncomplete = false;
|
||||
if (results.length <= limit) {
|
||||
for (let attempt = 0; attempt < MAX_BACKFILL_BATCHES_PER_REQUEST; attempt += 1) {
|
||||
const backfill = controlPlane.backfillAgentHistoryIndex(beforeOutboxId, BACKFILL_BATCH_LIMIT);
|
||||
if (backfill.scannedRows === 0) {
|
||||
backfillIncomplete = false;
|
||||
break;
|
||||
}
|
||||
backfillIncomplete = !backfill.exhausted;
|
||||
results = controlPlane.eventsBeforeForAgent(normalizedAgentId, beforeOutboxId, limit + 1);
|
||||
if (results.length > limit || backfill.exhausted) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const hasMore = results.length > limit || backfillIncomplete;
|
||||
const entries = results.length > limit ? results.slice(results.length - limit) : results;
|
||||
const nextBeforeOutboxId = hasMore
|
||||
? backfillIncomplete
|
||||
? beforeOutboxId
|
||||
: entries.length > 0
|
||||
? entries[0].id
|
||||
: null
|
||||
: null;
|
||||
|
||||
return NextResponse.json({
|
||||
enabled: true,
|
||||
agentId: normalizedAgentId,
|
||||
...(startError ? { error: startError } : {}),
|
||||
entries,
|
||||
hasMore,
|
||||
nextBeforeOutboxId,
|
||||
freshness: deriveRuntimeFreshness(snapshot, probe),
|
||||
...(probe ? { probe } : {}),
|
||||
});
|
||||
|
||||
@@ -287,7 +287,7 @@ describe("runtime routes", () => {
|
||||
expect(secondBody.nextBeforeOutboxId).toBeNull();
|
||||
});
|
||||
|
||||
it("agent history route backfills legacy rows in bounded batches", async () => {
|
||||
it("agent history route keeps cursor stable while backfill remains incomplete", async () => {
|
||||
const eventsBeforeForAgent = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce([
|
||||
@@ -380,7 +380,56 @@ describe("runtime routes", () => {
|
||||
expect(eventsBeforeForAgent).toHaveBeenNthCalledWith(2, "alpha", 11, 3);
|
||||
expect(body.entries.map((entry) => entry.id)).toEqual([5, 7]);
|
||||
expect(body.hasMore).toBe(true);
|
||||
expect(body.nextBeforeOutboxId).toBe(5);
|
||||
expect(body.nextBeforeOutboxId).toBe(11);
|
||||
});
|
||||
|
||||
it("agent history route reports continuation when backfill cap is reached before exhaustion", async () => {
|
||||
const eventsBeforeForAgent = vi.fn().mockReturnValue([]);
|
||||
const backfillAgentHistoryIndex = vi
|
||||
.fn()
|
||||
.mockReturnValue({ scannedRows: 500, updatedRows: 0, exhausted: false });
|
||||
const runtimeMock: RuntimeMock = {
|
||||
ensureStarted: async () => {},
|
||||
snapshot: () => ({
|
||||
status: "connected",
|
||||
reason: null,
|
||||
asOf: "2026-02-28T02:40:00.000Z",
|
||||
outboxHead: 10,
|
||||
}),
|
||||
eventsAfter: () => [],
|
||||
eventsBefore: () => {
|
||||
throw new Error("legacy scan path used");
|
||||
},
|
||||
eventsBeforeForAgent,
|
||||
backfillAgentHistoryIndex,
|
||||
subscribe: () => () => {},
|
||||
};
|
||||
|
||||
const mod = await loadRouteModule<{
|
||||
GET: (
|
||||
request: Request,
|
||||
context: { params: Promise<{ agentId: string }> }
|
||||
) => Promise<Response>;
|
||||
}>("@/app/api/runtime/agents/[agentId]/history/route", runtimeMock);
|
||||
|
||||
const response = await mod.GET(
|
||||
new Request("http://localhost/api/runtime/agents/alpha/history?limit=2"),
|
||||
{ params: Promise.resolve({ agentId: "alpha" }) }
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
entries: Array<{ id: number }>;
|
||||
hasMore: boolean;
|
||||
nextBeforeOutboxId: number | null;
|
||||
};
|
||||
expect(eventsBeforeForAgent).toHaveBeenNthCalledWith(1, "alpha", 11, 3);
|
||||
expect(eventsBeforeForAgent).toHaveBeenNthCalledWith(2, "alpha", 11, 3);
|
||||
expect(eventsBeforeForAgent).toHaveBeenNthCalledWith(3, "alpha", 11, 3);
|
||||
expect(backfillAgentHistoryIndex).toHaveBeenNthCalledWith(1, 11, 500);
|
||||
expect(backfillAgentHistoryIndex).toHaveBeenNthCalledWith(2, 11, 500);
|
||||
expect(body.entries).toEqual([]);
|
||||
expect(body.hasMore).toBe(true);
|
||||
expect(body.nextBeforeOutboxId).toBe(11);
|
||||
});
|
||||
|
||||
it("stream route replays from Last-Event-ID and emits live updates", async () => {
|
||||
|
||||
Reference in New Issue
Block a user