Files
PaperClipAI/server/src/services/skill-source-refresh-budget.ts
T
DottaandPaperclip 473c80d13f Automatically import and update project repository skills (#16066)
Automatically import skills from new GitHub project repositories and update enabled sources in the background. Preserve project-derived access, saved selections, immutable execution versions and explicit pins; add legacy adoption, refresh and connection repair in Sources.

Include durable scheduling and bounded work credits, production Storybook journeys, real GitHub-to-routine acceptance receipts and final-head CI/security/Greptile qualification.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 16:11:43 -05:00

44 lines
2.9 KiB
TypeScript

import { and, eq, sql } from 'drizzle-orm';
import { companySkillSources, type Db } from '@paperclipai/db';
import { conflict, tooManyRequests } from '../errors.js';
export const AUTOMATIC_SKILL_WORK_CAPACITY_BYTES = 512 * 1024 * 1024;
export const AUTOMATIC_SKILL_WORK_RESERVATION_BYTES = 128 * 1024 * 1024;
export const REFILL_WINDOW_MS = 60 * 60_000;
export function automaticSkillWorkAvailable(bytes: number, updatedAt: Date, now: Date) {
const elapsed = Math.max(0, now.getTime() - updatedAt.getTime());
return Math.min(AUTOMATIC_SKILL_WORK_CAPACITY_BYTES,
bytes + Math.floor(Math.min(elapsed, REFILL_WINDOW_MS) * AUTOMATIC_SKILL_WORK_CAPACITY_BYTES / REFILL_WINDOW_MS));
}
export function automaticSkillWorkRefund(work: { downloadBytes?: number; scanBytes?: number }) {
return AUTOMATIC_SKILL_WORK_RESERVATION_BYTES - Math.ceil(Math.min(AUTOMATIC_SKILL_WORK_RESERVATION_BYTES,
Math.max(work.downloadBytes ?? AUTOMATIC_SKILL_WORK_RESERVATION_BYTES, work.scanBytes ?? AUTOMATIC_SKILL_WORK_RESERVATION_BYTES, 0)));
}
/** The refresh lease serializes this durable token bucket across workers/restarts. */
export async function reserveAutomaticSkillWork(db: Db, source: typeof companySkillSources.$inferSelect, token: string, now: Date) {
const effectiveNow = new Date(Math.max(now.getTime(), source.automaticBudgetAt.getTime()));
const available = automaticSkillWorkAvailable(source.automaticBudgetBytes, source.automaticBudgetAt, effectiveNow);
if (available < AUTOMATIC_SKILL_WORK_RESERVATION_BYTES) {
throw tooManyRequests('Automatic refresh is temporarily limited. Use Refresh now, or wait for automatic updates to resume.', {
code: 'skill_source_automatic_work_limited',
retryAfterSeconds: Math.ceil((AUTOMATIC_SKILL_WORK_RESERVATION_BYTES - available) * REFILL_WINDOW_MS / AUTOMATIC_SKILL_WORK_CAPACITY_BYTES / 1000),
});
}
const changed = await db.update(companySkillSources).set({ automaticBudgetAt: effectiveNow,
automaticBudgetBytes: available - AUTOMATIC_SKILL_WORK_RESERVATION_BYTES })
.where(and(eq(companySkillSources.companyId, source.companyId), eq(companySkillSources.id, source.id),
eq(companySkillSources.leaseToken, token))).returning({ id: companySkillSources.id });
if (!changed.length) throw conflict('Source refresh changed. Try again.');
}
/** Small repositories refund unused capacity; failures keep the bounded reservation. */
export async function settleAutomaticSkillWork(db: Db, companyId: string, sourceId: string, token: string,
work: { downloadBytes?: number; scanBytes?: number }) {
const refund = automaticSkillWorkRefund(work);
if (!refund) return;
await db.update(companySkillSources).set({ automaticBudgetBytes: sql`least(${AUTOMATIC_SKILL_WORK_CAPACITY_BYTES}, ${companySkillSources.automaticBudgetBytes} + ${refund})` })
.where(and(eq(companySkillSources.companyId, companyId), eq(companySkillSources.id, sourceId), eq(companySkillSources.leaseToken, token)));
}