mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 08:28:48 +01:00
Reuse bounded timezone formatters and upcoming openings only within their exact searched closed ranges. Short-circuit empty windows while preserving the existing cold DST search and precise instant eligibility. Verify cost bounds, cache eviction, timezone transitions and twenty-card ordinary source delivery. Co-Authored-By: Paperclip <paperclip@paperclip.ing>
236 lines
11 KiB
TypeScript
236 lines
11 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import type { CompanySearchIssueSummary, StatusCardRefreshPolicy } from "@paperclipai/shared";
|
|
|
|
export type StatusCardFingerprintEntry = {
|
|
status: string;
|
|
updatedAt: string;
|
|
latestHumanCommentAt?: string | null;
|
|
identifier?: string | null;
|
|
title?: string;
|
|
assigneeAgentId?: string | null;
|
|
assigneeUserId?: string | null;
|
|
};
|
|
|
|
export type StatusCardFingerprint = Record<string, StatusCardFingerprintEntry>;
|
|
|
|
export type StatusCardDeltaChange = {
|
|
issueId: string;
|
|
identifier: string;
|
|
title: string;
|
|
from: string | null;
|
|
to: string | null;
|
|
changeKind: "new" | "removed" | "status" | "assignee" | "human_comment" | "updated";
|
|
};
|
|
|
|
/** Upper bound on summary-mentioned issues joined to a card's watched set. */
|
|
export const STATUS_CARD_MAX_MENTIONED_ISSUES = 200;
|
|
|
|
const ISSUE_IDENTIFIER_MENTION_PATTERN = /\b[A-Z][A-Z0-9]{0,9}-\d{1,7}\b/g;
|
|
const ISSUE_LINK_MENTION_PATTERN = /\/issues\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\b/g;
|
|
|
|
/**
|
|
* Pull issue references out of summary markdown: bare identifiers ("PAP-123")
|
|
* and issue links carrying a UUID ("/issues/<id>"). Callers must resolve the
|
|
* candidates against the card's company before trusting them.
|
|
*/
|
|
export function extractIssueMentions(markdown: string) {
|
|
const identifiers = new Set<string>();
|
|
const issueIds = new Set<string>();
|
|
for (const match of markdown.matchAll(ISSUE_IDENTIFIER_MENTION_PATTERN)) identifiers.add(match[0]);
|
|
for (const match of markdown.matchAll(ISSUE_LINK_MENTION_PATTERN)) issueIds.add(match[1]!.toLowerCase());
|
|
return { identifiers: [...identifiers], issueIds: [...issueIds] };
|
|
}
|
|
|
|
export function buildStatusCardFingerprint(issues: Array<CompanySearchIssueSummary & { latestHumanCommentAt?: string | null }>): StatusCardFingerprint {
|
|
return Object.fromEntries(issues.map((issue) => [issue.id, {
|
|
status: issue.status,
|
|
updatedAt: issue.updatedAt,
|
|
latestHumanCommentAt: issue.latestHumanCommentAt ?? null,
|
|
identifier: issue.identifier,
|
|
title: issue.title,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
assigneeUserId: issue.assigneeUserId,
|
|
}]));
|
|
}
|
|
|
|
export function diffStatusCardFingerprint(previous: StatusCardFingerprint | null, current: StatusCardFingerprint) {
|
|
const changes: StatusCardDeltaChange[] = [];
|
|
const before = previous ?? {};
|
|
for (const [issueId, next] of Object.entries(current)) {
|
|
const prior = before[issueId];
|
|
if (!prior) {
|
|
changes.push({ issueId, identifier: next.identifier ?? issueId, title: next.title ?? "", from: null, to: next.status, changeKind: "new" });
|
|
continue;
|
|
}
|
|
let hasSpecificChange = false;
|
|
if (prior.status !== next.status) {
|
|
changes.push({ issueId, identifier: next.identifier ?? issueId, title: next.title ?? "", from: prior.status, to: next.status, changeKind: "status" });
|
|
hasSpecificChange = true;
|
|
}
|
|
if (prior.assigneeAgentId !== next.assigneeAgentId || prior.assigneeUserId !== next.assigneeUserId) {
|
|
changes.push({ issueId, identifier: next.identifier ?? issueId, title: next.title ?? "", from: null, to: null, changeKind: "assignee" });
|
|
hasSpecificChange = true;
|
|
}
|
|
if (prior.latestHumanCommentAt !== next.latestHumanCommentAt && next.latestHumanCommentAt) {
|
|
changes.push({ issueId, identifier: next.identifier ?? issueId, title: next.title ?? "", from: prior.latestHumanCommentAt ?? null, to: next.latestHumanCommentAt, changeKind: "human_comment" });
|
|
hasSpecificChange = true;
|
|
}
|
|
if (prior.updatedAt !== next.updatedAt && !hasSpecificChange) {
|
|
changes.push({ issueId, identifier: next.identifier ?? issueId, title: next.title ?? "", from: prior.status, to: next.status, changeKind: "updated" });
|
|
}
|
|
}
|
|
for (const [issueId, prior] of Object.entries(before)) {
|
|
if (current[issueId]) continue;
|
|
changes.push({ issueId, identifier: prior.identifier ?? issueId, title: prior.title ?? "", from: prior.status, to: null, changeKind: "removed" });
|
|
}
|
|
return changes;
|
|
}
|
|
|
|
export function filterStatusCardChanges(changes: StatusCardDeltaChange[], policy: StatusCardRefreshPolicy) {
|
|
return changes.filter((change) => {
|
|
if (policy.triggers.anyUpdate) return true;
|
|
if ((change.changeKind === "new" || change.changeKind === "removed") && policy.triggers.membershipChanges) return true;
|
|
if (change.changeKind === "assignee" && policy.triggers.assigneeChanges) return true;
|
|
if (change.changeKind === "human_comment" && policy.triggers.humanComments) return true;
|
|
if (change.changeKind === "status" && policy.triggers.statusTransitions) return true;
|
|
return false;
|
|
});
|
|
}
|
|
|
|
export function statusCardChangesHash(changes: StatusCardDeltaChange[]) {
|
|
const stable = [...changes]
|
|
.map(({ issueId, changeKind, from, to }) => ({ issueId, changeKind, from, to }))
|
|
.sort((left, right) => `${left.issueId}:${left.changeKind}`.localeCompare(`${right.issueId}:${right.changeKind}`));
|
|
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
|
|
}
|
|
|
|
export function statusCardFingerprintHash(fingerprint: StatusCardFingerprint) {
|
|
const stable = Object.fromEntries(Object.entries(fingerprint).sort(([left], [right]) => left.localeCompare(right)));
|
|
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
|
|
}
|
|
|
|
// These caches contain only timezone/policy calculations, never card or company
|
|
// data. Bound both maps because timezone aliases and hour pairs are user input.
|
|
const activeHoursFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
const upcomingActiveHours = new Map<string, { closedAt: number; opensAt: number }>();
|
|
|
|
function remember<K, V>(cache: Map<K, V>, key: K, value: V, limit: number): V {
|
|
cache.delete(key);
|
|
cache.set(key, value);
|
|
if (cache.size > limit) cache.delete(cache.keys().next().value!);
|
|
return value;
|
|
}
|
|
|
|
function activeHoursFormatter(timezone: string) {
|
|
const cached = activeHoursFormatters.get(timezone);
|
|
return remember(activeHoursFormatters, timezone, cached ?? new Intl.DateTimeFormat("en-GB", {
|
|
timeZone: timezone, hour: "2-digit", minute: "2-digit", hourCycle: "h23",
|
|
}), 32);
|
|
}
|
|
|
|
export function isWithinStatusCardActiveHours(policy: StatusCardRefreshPolicy, now: Date) {
|
|
if (!policy.activeHours) return true;
|
|
const [startHour, startMinute] = policy.activeHours.start.split(":").map(Number);
|
|
const [endHour, endMinute] = policy.activeHours.end.split(":").map(Number);
|
|
const start = startHour! * 60 + startMinute!;
|
|
const end = endHour! * 60 + endMinute!;
|
|
if (start === end) return false;
|
|
const parts = activeHoursFormatter(policy.activeHours.timezone).formatToParts(now);
|
|
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? 0);
|
|
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? 0);
|
|
const current = hour * 60 + minute;
|
|
return start <= end ? current >= start && current < end : current >= start || current < end;
|
|
}
|
|
|
|
export function nextStatusCardEvaluationAt(policy: StatusCardRefreshPolicy, now: Date) {
|
|
if (policy.mode !== "interval") return null;
|
|
const seconds = (policy.intervalMinutes ?? 15) * 60;
|
|
return new Date(now.getTime() + seconds * 1000);
|
|
}
|
|
|
|
export function chooseStatusCardUpdateKind(input: {
|
|
explicitFull?: boolean;
|
|
hasDocument: boolean;
|
|
changeCount: number;
|
|
queryVersion: number;
|
|
lastUpdateQueryVersion: number | null;
|
|
incrementalCount: number;
|
|
configurationChanged: boolean;
|
|
restoreRefresh?: boolean;
|
|
}) {
|
|
if (
|
|
input.explicitFull || !input.hasDocument || input.changeCount > 10 || input.configurationChanged ||
|
|
input.restoreRefresh || input.lastUpdateQueryVersion !== input.queryVersion || input.incrementalCount >= 9
|
|
) return "full" as const;
|
|
return "incremental" as const;
|
|
}
|
|
|
|
export function evaluateStatusCardPolicy(input: {
|
|
policy: StatusCardRefreshPolicy;
|
|
now: Date;
|
|
lastChangeAt: Date | null;
|
|
updatesLastHour: number;
|
|
tokensToday: number;
|
|
manual: boolean;
|
|
}) {
|
|
const cap = input.policy.dailyTokenCap ?? 100_000;
|
|
if (!input.manual && input.tokensToday >= cap) return { action: "pause_budget" as const };
|
|
if (!input.manual && !isWithinStatusCardActiveHours(input.policy, input.now)) return { action: "pause_hours" as const };
|
|
if (input.manual) return { action: "run" as const };
|
|
if (input.policy.mode === "manual") return { action: "wait" as const };
|
|
if (input.policy.mode === "reactive") {
|
|
if (input.updatesLastHour >= (input.policy.maxUpdatesPerHour ?? 6)) return { action: "wait" as const };
|
|
const dueAt = new Date((input.lastChangeAt ?? input.now).getTime() + (input.policy.debounceSeconds ?? 60) * 1000);
|
|
if (dueAt > input.now) return { action: "wait" as const, dueAt };
|
|
}
|
|
return { action: "run" as const };
|
|
}
|
|
|
|
/** A retry exists only while filtered changes remain. Use the owning policy's
|
|
* actual eligibility boundary instead of repeatedly rediscovering a closed gate. */
|
|
export function nextStatusCardPolicyEligibility(input: {
|
|
policy: StatusCardRefreshPolicy;
|
|
now: Date;
|
|
lastChangeAt: Date;
|
|
completedUpdateTimes: Date[];
|
|
tokensToday: number;
|
|
}): Date | null {
|
|
if (input.policy.mode === "manual") return null;
|
|
const hours = input.policy.activeHours;
|
|
if (hours && hours.start === hours.end) return null;
|
|
let at = input.now.getTime();
|
|
if (input.tokensToday >= (input.policy.dailyTokenCap ?? 100_000)) {
|
|
const day = new Date(input.now);
|
|
day.setUTCHours(24, 0, 0, 0);
|
|
at = Math.max(at, day.getTime());
|
|
}
|
|
if (input.policy.mode === "reactive") {
|
|
at = Math.max(at, input.lastChangeAt.getTime() + (input.policy.debounceSeconds ?? 60) * 1000);
|
|
const cap = input.policy.maxUpdatesPerHour ?? 6;
|
|
const recent = input.completedUpdateTimes.map(time => time.getTime())
|
|
.filter(time => time > at - 3_600_000).sort((a, b) => a - b);
|
|
if (recent.length >= cap) at = Math.max(at, recent[recent.length - cap]! + 3_600_000 + 1);
|
|
}
|
|
// Check the precise instant before reusing a minute-aligned opening. Some
|
|
// historical timezone offsets include seconds, so a window may be open now.
|
|
if (!hours || isWithinStatusCardActiveHours(input.policy, new Date(at))) return new Date(at);
|
|
const key = JSON.stringify([hours.timezone, hours.start, hours.end]);
|
|
const cached = upcomingActiveHours.get(key);
|
|
if (cached && at >= cached.closedAt && at < cached.opensAt) {
|
|
remember(upcomingActiveHours, key, cached, 256);
|
|
return new Date(cached.opensAt);
|
|
}
|
|
const closedAt = at;
|
|
// Active-hour precision is one local minute. Walking UTC minutes also honors
|
|
// skipped/repeated local times at daylight-saving transitions.
|
|
const end = at + 48 * 3_600_000;
|
|
for (at = Math.floor(at / 60_000) * 60_000 + 60_000; at <= end; at += 60_000) {
|
|
if (isWithinStatusCardActiveHours(input.policy, new Date(at))) {
|
|
remember(upcomingActiveHours, key, { closedAt, opensAt: at }, 256);
|
|
return new Date(at);
|
|
}
|
|
}
|
|
// No opening inside the existing bounded search horizon. Retain changes.
|
|
return null;
|
|
}
|