diff --git a/src/api/routes.ts b/src/api/routes.ts index 8db71ad86c..986175a26b 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -142,6 +142,7 @@ import { CONTRIBUTOR_DECISION_PACK_SIGNAL, loadContributorDecisionPackForServing, repoDecisionFromPack, + tryEnqueueDecisionPackRebuild, } from "../services/decision-pack"; import { buildMinerDashboardNextActions, @@ -1108,8 +1109,8 @@ export function createApp() { if (!login) return c.json({ error: "login_required" }, 400); const unauthorized = await requireContributorAccess(c, login); if (unauthorized) return unauthorized; - const message: JobMessage = { type: "build-contributor-decision-packs", requestedBy: "api", login }; - await c.env.JOBS.send(message); + const queued = await tryEnqueueDecisionPackRebuild(c.env, login); + if (!queued) return c.json({ error: "refresh_enqueue_failed", login }, 503); return c.json({ status: "queued", login }, 202); }); diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts index f744937b87..300fba020d 100644 --- a/src/auth/rate-limit.ts +++ b/src/auth/rate-limit.ts @@ -103,6 +103,7 @@ export function routeClassForPath(path: string): RateLimitClass { path.includes("/v1/agent/") || path.includes("/scoring/preview") || path.includes("/decision-pack") || + path.includes("/miner-dashboard/refresh") || path.includes("/open-pr-monitor") || // Maintainer BYOK config: POST /ai-key runs PBKDF2 (100k iters) + an encrypted D1 upsert per request. /\/ai-(?:key|review)$/.test(path) || diff --git a/src/services/decision-pack.ts b/src/services/decision-pack.ts index dedca3f071..7106d7803a 100644 --- a/src/services/decision-pack.ts +++ b/src/services/decision-pack.ts @@ -353,7 +353,7 @@ export async function loadContributorDecisionPackForServing( }; } -async function tryEnqueueDecisionPackRebuild(env: Env, login: string): Promise { +export async function tryEnqueueDecisionPackRebuild(env: Env, login: string): Promise { const pending = pendingDecisionPackRebuilds.get(login); if (pending) return pending; const sinceIso = new Date(Date.now() - DECISION_PACK_REBUILD_DEBOUNCE_MS).toISOString(); diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index c98a0d0d1d..cf063318f9 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -2075,6 +2075,14 @@ describe("api routes", () => { const refreshQueued = await app.request("/v1/app/miner-dashboard/refresh?login=oktofeesh1", { method: "POST", headers: apiHeaders(env) }, env); expect(refreshQueued.status).toBe(202); await expect(refreshQueued.json()).resolves.toMatchObject({ status: "queued", login: "oktofeesh1" }); + const refreshDuplicate = await app.request("/v1/app/miner-dashboard/refresh?login=oktofeesh1", { method: "POST", headers: apiHeaders(env) }, env); + expect(refreshDuplicate.status).toBe(202); + const queuedRefreshRows = ( + (await env.DB.prepare("SELECT COUNT(*) AS count FROM audit_events WHERE event_type='decision_pack.rebuild_enqueued' AND actor='oktofeesh1'").all()) as { + results: Array<{ count: number }>; + } + ).results; + expect(queuedRefreshRows[0]?.count).toBe(1); // No ?login → the login resolves from the session actor (covers the session-actor fallback). const refreshSelf = await app.request("/v1/app/miner-dashboard/refresh", { method: "POST", headers: { cookie: `gittensory_session=${otherToken}` } }, env); expect(refreshSelf.status).toBe(202); diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index f5e1a9dfe0..f28380ec06 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -85,6 +85,7 @@ describe("private-beta auth and rate limiting", () => { expect(routeClassForPath("/v1/scoring/preview")).toBe("expensive"); expect(routeClassForPath("/v1/upstream/status")).toBe("expensive"); expect(routeClassForPath("/v1/contributors/jsonbored/decision-pack")).toBe("expensive"); + expect(routeClassForPath("/v1/app/miner-dashboard/refresh")).toBe("expensive"); expect(routeClassForPath("/v1/contributors/jsonbored/open-pr-monitor")).toBe("expensive"); expect(routeClassForPath("/v1/installations/999/repair/refresh")).toBe("expensive"); expect(routeClassForPath("/v1/internal/jobs/generate-signal-snapshots")).toBe("expensive");