|
|
|
@@ -9,14 +9,15 @@ import { Cursor, InstallationBody, InstallationId, MAX_AGE_MS, MAX_ATTEMPTS, Not
|
|
|
|
|
digest, fail, ownerEpoch, parseSafe, validateSubscription, type Context, type Identity, type Presence, type Registration } from './agent-push-contract.js';
|
|
|
|
|
import { createPushSender, type PushSend } from './agent-push-transports.js';
|
|
|
|
|
import { createMessageText, type MessageText } from './agent-push-preview.js';
|
|
|
|
|
import { subscribeNotificationEvents } from './agent-push-events.js';
|
|
|
|
|
|
|
|
|
|
type Tx = Prisma.TransactionClient;
|
|
|
|
|
export type ReadState = { cursor: string; updatedAt: string | null; sourceId: string | null };
|
|
|
|
|
type Options = { db: PrismaClient; config: PushConfig; context: (after?: string, sourceId?: string) => Promise<Context>; messageText: MessageText; send: PushSend; now?: () => number;
|
|
|
|
|
broadcast?: (state: ReadState) => void; warmupMs?: number };
|
|
|
|
|
broadcast?: (state: ReadState) => void; warmupMs?: number; subscribe?: (changed: () => void) => (() => void) };
|
|
|
|
|
const date = (ms: number) => new Date(ms);
|
|
|
|
|
const activeStatuses = ['pending', 'intent'];
|
|
|
|
|
type Suppression = { reason: string; clientKind: 'web' | 'unknown' | 'runtime_web' | 'runtime_unknown' | 'system' };
|
|
|
|
|
type Suppression = { until?: number; reason: string; clientKind: 'web' | 'unknown' | 'runtime_web' | 'runtime_unknown' | 'system' };
|
|
|
|
|
|
|
|
|
|
/** Sole coordinator: persisted source checkpoint, installations, presence, claims and outcomes. */
|
|
|
|
|
export class AgentPushCoordinator {
|
|
|
|
@@ -27,7 +28,15 @@ export class AgentPushCoordinator {
|
|
|
|
|
private started = false;
|
|
|
|
|
private untrackedConnections = new Map<string, { at: number; kind: 'web' | 'unknown' }>();
|
|
|
|
|
private running = false;
|
|
|
|
|
private timer?: ReturnType<typeof setInterval>;
|
|
|
|
|
private timer?: ReturnType<typeof setTimeout>;
|
|
|
|
|
private timerAt = Infinity;
|
|
|
|
|
private enabled = false;
|
|
|
|
|
private dirty = false;
|
|
|
|
|
private reconsider = true;
|
|
|
|
|
private unsubscribe?: () => void;
|
|
|
|
|
private nextMaintenance = 0;
|
|
|
|
|
private nextRuntimeChange?: number;
|
|
|
|
|
private failures = 0;
|
|
|
|
|
private stopped = false;
|
|
|
|
|
private observedRoot?: { sourceId: string; conversationId: string };
|
|
|
|
|
constructor(readonly options: Options) {
|
|
|
|
@@ -51,11 +60,37 @@ export class AgentPushCoordinator {
|
|
|
|
|
this.started = true;
|
|
|
|
|
}
|
|
|
|
|
start() {
|
|
|
|
|
if (this.timer || !this.options.config.adminToken) return;
|
|
|
|
|
this.timer = setInterval(() => { void this.tick(); }, 2_000); this.timer.unref();
|
|
|
|
|
void this.tick();
|
|
|
|
|
if (this.enabled || !this.options.config.adminToken) return;
|
|
|
|
|
this.enabled = true;
|
|
|
|
|
this.unsubscribe = this.options.subscribe?.(() => this.wake());
|
|
|
|
|
this.wake();
|
|
|
|
|
}
|
|
|
|
|
/** Coalesce events without dropping one that arrives during reconciliation. */
|
|
|
|
|
wake() {
|
|
|
|
|
this.reconsider = true;
|
|
|
|
|
if (!this.enabled || this.stopped) return;
|
|
|
|
|
this.dirty = true;
|
|
|
|
|
if (!this.running) this.schedule(this.now() + 100);
|
|
|
|
|
}
|
|
|
|
|
private schedule(at: number) {
|
|
|
|
|
if (!this.enabled || this.stopped || at >= this.timerAt) return;
|
|
|
|
|
clearTimeout(this.timer);
|
|
|
|
|
this.timerAt = at;
|
|
|
|
|
this.timer = setTimeout(() => { this.timer = undefined; this.timerAt = Infinity; void this.tick(); }, Math.max(0, at - this.now()));
|
|
|
|
|
this.timer.unref();
|
|
|
|
|
}
|
|
|
|
|
private async schedulePending() {
|
|
|
|
|
const [pending, intent] = await Promise.all([
|
|
|
|
|
this.options.db.agentPushDelivery.findFirst({ where: { status: 'pending' }, orderBy: { dueAt: 'asc' }, select: { dueAt: true } }),
|
|
|
|
|
this.options.db.agentPushDelivery.findFirst({ where: { status: 'intent' }, orderBy: { updatedAt: 'asc' }, select: { updatedAt: true } }),
|
|
|
|
|
]);
|
|
|
|
|
this.schedule(Math.max(this.now() + 100, Math.min(this.nextMaintenance,
|
|
|
|
|
pending?.dueAt.getTime() ?? Infinity, intent ? intent.updatedAt.getTime() + 30_001 : Infinity)));
|
|
|
|
|
}
|
|
|
|
|
async stop() {
|
|
|
|
|
this.stopped = true; this.unsubscribe?.(); clearTimeout(this.timer);
|
|
|
|
|
while (this.running) await new Promise(r => setTimeout(r, 20));
|
|
|
|
|
}
|
|
|
|
|
async stop() { this.stopped = true; if (this.timer) clearInterval(this.timer); while (this.running) await new Promise(r => setTimeout(r, 20)); }
|
|
|
|
|
async config() {
|
|
|
|
|
await this.initialize();
|
|
|
|
|
const state = await this.options.db.agentPushCoordinator.findUnique({ where: { id: 'root' } });
|
|
|
|
@@ -73,7 +108,7 @@ export class AgentPushCoordinator {
|
|
|
|
|
value = { ...value, token: value.token.toLowerCase() };
|
|
|
|
|
} else { if (!config.web) throw fail(503, 'Web Push is unavailable'); validateSubscription(value.subscription, this.now()); }
|
|
|
|
|
const payload = JSON.stringify(value), fingerprint = digest(value.transport === 'apns' ? `${value.environment}:${value.topic}:${value.token}` : value.subscription.endpoint);
|
|
|
|
|
return this.options.db.$transaction(async tx => {
|
|
|
|
|
const result = await this.options.db.$transaction(async tx => {
|
|
|
|
|
const old = await tx.agentPushInstallation.findUnique({ where: { id } });
|
|
|
|
|
if (old?.revokedAt) throw fail(409, 'Installation was retired; explicit opt-in requires a new installation ID');
|
|
|
|
|
if (old?.enabled && old.ownerEpoch === this.epoch && old.payload === payload) return { installationId: id, transport: old.transport, enabled: true, version: old.version };
|
|
|
|
@@ -89,6 +124,8 @@ export class AgentPushCoordinator {
|
|
|
|
|
update: { ownerEpoch: this.epoch, transport: value.transport, payload, fingerprint, enabled: true, version: { increment: 1 } } });
|
|
|
|
|
return { installationId: id, transport: record.transport, enabled: record.enabled, version: record.version };
|
|
|
|
|
});
|
|
|
|
|
this.wake();
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
async revoke(id: string) {
|
|
|
|
|
await this.options.db.$transaction(async tx => {
|
|
|
|
@@ -98,10 +135,11 @@ export class AgentPushCoordinator {
|
|
|
|
|
update: { enabled: false, revokedAt: date(this.now()), version: { increment: 1 }, payload: '', fingerprint: '' } });
|
|
|
|
|
await tx.agentPushDelivery.updateMany({ where: { installationId: id, status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'revoked' } });
|
|
|
|
|
});
|
|
|
|
|
this.wake();
|
|
|
|
|
}
|
|
|
|
|
async presence(id: string, value: Presence) {
|
|
|
|
|
const now = this.now();
|
|
|
|
|
return this.options.db.$transaction(async tx => {
|
|
|
|
|
const result = await this.options.db.$transaction(async tx => {
|
|
|
|
|
const old = await tx.agentPushPresence.findUnique({ where: { id } });
|
|
|
|
|
if (old?.ownerEpoch === this.epoch && value.sequence <= old.sequence) return { expiresAt: old.expiresAt.toISOString() };
|
|
|
|
|
if (!old && await tx.agentPushPresence.count({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } }) >= 512) throw fail(429, 'Too many presence sessions');
|
|
|
|
@@ -115,6 +153,8 @@ export class AgentPushCoordinator {
|
|
|
|
|
const row = await tx.agentPushPresence.upsert({ where: { id }, create: { id, ...data }, update: data });
|
|
|
|
|
return { expiresAt: row.expiresAt.toISOString() };
|
|
|
|
|
});
|
|
|
|
|
this.wake();
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
async retirePresence(id: string) {
|
|
|
|
|
await this.options.db.$transaction(async tx => {
|
|
|
|
@@ -123,6 +163,7 @@ export class AgentPushCoordinator {
|
|
|
|
|
await tx.agentPushPresence.update({ where: { id }, data: { visible: false, focused: false,
|
|
|
|
|
inactiveUntil: old.visible && old.focused && old.expiresAt.getTime() > this.now() ? date(this.now() + PUSH_POLICY.inactiveGraceMs) : old.inactiveUntil } });
|
|
|
|
|
});
|
|
|
|
|
this.wake();
|
|
|
|
|
}
|
|
|
|
|
async connect(id: string, kind?: string, sessionId?: string) {
|
|
|
|
|
const now = this.now();
|
|
|
|
@@ -140,17 +181,19 @@ export class AgentPushCoordinator {
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
this.untrackedConnections.delete(id);
|
|
|
|
|
this.wake();
|
|
|
|
|
}
|
|
|
|
|
async heartbeat(id: string) {
|
|
|
|
|
// Transport liveness never creates a client, changes its attribution or proves attention.
|
|
|
|
|
await this.options.db.agentPushConnection.updateMany({ where: { id, ownerEpoch: this.epoch }, data: { expiresAt: date(this.now() + PUSH_POLICY.presenceLeaseMs) } });
|
|
|
|
|
}
|
|
|
|
|
async disconnect(id: string) { this.untrackedConnections.delete(id); await this.options.db.agentPushConnection.deleteMany({ where: { id, ownerEpoch: this.epoch } }); }
|
|
|
|
|
async disconnect(id: string) { this.untrackedConnections.delete(id); await this.options.db.agentPushConnection.deleteMany({ where: { id, ownerEpoch: this.epoch } }); this.wake(); }
|
|
|
|
|
async notePrompt() {
|
|
|
|
|
if (!this.options.config.adminToken) return;
|
|
|
|
|
await this.initialize();
|
|
|
|
|
// Covers submissions arriving after the last runtime snapshot but before the send claim.
|
|
|
|
|
await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch }, data: { localPromptAt: date(this.now()) } });
|
|
|
|
|
this.wake();
|
|
|
|
|
}
|
|
|
|
|
async readState(tx: Tx | PrismaClient = this.options.db): Promise<ReadState> {
|
|
|
|
|
const row = await tx.agentReadState.findUnique({ where: { id: 'root' } });
|
|
|
|
@@ -193,7 +236,7 @@ export class AgentPushCoordinator {
|
|
|
|
|
const changed = await this.mergeRead(tx, cursor, coordinator?.sourceId || null);
|
|
|
|
|
return { state: changed || await this.readState(tx), changed: Boolean(changed) };
|
|
|
|
|
});
|
|
|
|
|
if (result.changed) this.options.broadcast?.(result.state);
|
|
|
|
|
if (result.changed) { this.options.broadcast?.(result.state); this.wake(); }
|
|
|
|
|
return result.state;
|
|
|
|
|
}
|
|
|
|
|
private async acquire(): Promise<boolean> {
|
|
|
|
@@ -263,22 +306,26 @@ export class AgentPushCoordinator {
|
|
|
|
|
return changedRead;
|
|
|
|
|
});
|
|
|
|
|
if (read) this.options.broadcast?.(read);
|
|
|
|
|
this.nextRuntimeChange = context.nextChangeAt ? Date.parse(context.nextChangeAt) : undefined;
|
|
|
|
|
this.observedRoot = { sourceId: context.sourceId, conversationId: context.conversationId };
|
|
|
|
|
return context.hasMore;
|
|
|
|
|
}
|
|
|
|
|
private async browserSuppression(tx: Tx, now: number): Promise<Suppression | undefined> {
|
|
|
|
|
const observations = await tx.agentPushPresence.findMany({ where: { ownerEpoch: this.epoch } });
|
|
|
|
|
const presences = observations.filter(v => v.expiresAt.getTime() > now);
|
|
|
|
|
if (presences.some(v => v.visible && v.focused)) return { reason: 'foreground_focused', clientKind: 'web' };
|
|
|
|
|
if (presences.some(v => v.lastInteractionAt && v.lastInteractionAt.getTime() + PUSH_POLICY.interactionGraceMs > now)) return { reason: 'recent_interaction', clientKind: 'web' };
|
|
|
|
|
if (presences.some(v => v.inactiveUntil && v.inactiveUntil.getTime() > now)) return { reason: 'inactive_grace', clientKind: 'web' };
|
|
|
|
|
const focused = presences.filter(v => v.visible && v.focused);
|
|
|
|
|
if (focused.length) return { reason: 'foreground_focused', clientKind: 'web', until: Math.min(...focused.map(v => v.expiresAt.getTime())) };
|
|
|
|
|
const interactions = presences.map(v => v.lastInteractionAt ? Math.min(v.expiresAt.getTime(), v.lastInteractionAt.getTime() + PUSH_POLICY.interactionGraceMs) : 0).filter(at => at > now);
|
|
|
|
|
if (interactions.length) return { reason: 'recent_interaction', clientKind: 'web', until: Math.min(...interactions) };
|
|
|
|
|
const inactive = presences.map(v => Math.min(v.expiresAt.getTime(), v.inactiveUntil?.getTime() || 0)).filter(at => at > now);
|
|
|
|
|
if (inactive.length) return { reason: 'inactive_grace', clientKind: 'web', until: Math.min(...inactive) };
|
|
|
|
|
const connections = await tx.agentPushConnection.findMany({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } });
|
|
|
|
|
const unobserved = connections.filter(v => v.kind !== 'native' && (v.kind !== 'web' || !observations.some(p => p.id === v.sessionId)));
|
|
|
|
|
if (!unobserved.length && !this.untrackedConnections.size) return;
|
|
|
|
|
const firstUntracked = [...this.untrackedConnections.values()].sort((a, b) => a.at - b.at)[0];
|
|
|
|
|
await tx.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch, unknownSince: null }, data: { unknownSince: date(firstUntracked?.at ?? now) } });
|
|
|
|
|
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
|
|
|
|
if (state.unknownSince!.getTime() + PUSH_POLICY.unknownGraceMs > now) return { reason: 'unknown_client_grace',
|
|
|
|
|
if (state.unknownSince!.getTime() + PUSH_POLICY.unknownGraceMs > now) return { reason: 'unknown_client_grace', until: state.unknownSince!.getTime() + PUSH_POLICY.unknownGraceMs,
|
|
|
|
|
clientKind: unobserved[0]?.kind === 'web' ? 'web' : firstUntracked?.kind || 'unknown' };
|
|
|
|
|
}
|
|
|
|
|
private async claim(id: string, commit: boolean) {
|
|
|
|
@@ -287,8 +334,8 @@ export class AgentPushCoordinator {
|
|
|
|
|
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
|
|
|
|
const row = await tx.agentPushDelivery.findUnique({ where: { id } });
|
|
|
|
|
if (!row || row.status !== 'pending' || row.dueAt.getTime() > now) return;
|
|
|
|
|
const suppress = async ({ reason, clientKind }: Suppression) => {
|
|
|
|
|
await tx.agentPushDelivery.update({ where: { id }, data: { dueAt: date(now + PUSH_POLICY.debounceMs),
|
|
|
|
|
const suppress = async ({ reason, clientKind, until }: Suppression) => {
|
|
|
|
|
await tx.agentPushDelivery.update({ where: { id }, data: { dueAt: date(Math.min(row.expiresAt.getTime(), Math.max(now + 100, until ?? now + 30_000))),
|
|
|
|
|
lastSuppressionReason: reason, lastSuppressionClientKind: clientKind, lastSuppressedAt: date(now) } });
|
|
|
|
|
};
|
|
|
|
|
const installation = await tx.agentPushInstallation.findUnique({ where: { id: row.installationId } });
|
|
|
|
@@ -299,15 +346,15 @@ export class AgentPushCoordinator {
|
|
|
|
|
}
|
|
|
|
|
if (!this.options.config.enabled || state.lockOwner !== this.instanceId || !state.lockUntil || state.lockUntil.getTime() <= now
|
|
|
|
|
|| state.uncertain || !state.contextObservedAt || state.contextObservedAt.getTime() < now - 10_000 || now < this.warmUntil) {
|
|
|
|
|
await suppress({ reason: now < this.warmUntil ? 'startup_warmup' : 'source_or_coordinator_unavailable', clientKind: 'system' }); return;
|
|
|
|
|
await suppress({ reason: now < this.warmUntil ? 'startup_warmup' : 'source_or_coordinator_unavailable', clientKind: 'system', until: now < this.warmUntil ? this.warmUntil : this.nextRuntimeChange }); return;
|
|
|
|
|
}
|
|
|
|
|
if (row.kind === 'message') {
|
|
|
|
|
const attention: Suppression | undefined = state.nativeActive ? { reason: 'runtime_active', clientKind: 'runtime_web' }
|
|
|
|
|
: state.nativeUnknown ? { reason: 'runtime_unknown', clientKind: 'runtime_unknown' } : await this.browserSuppression(tx, now);
|
|
|
|
|
const attention: Suppression | undefined = state.nativeActive ? { reason: 'runtime_active', clientKind: 'runtime_web', until: this.nextRuntimeChange }
|
|
|
|
|
: state.nativeUnknown ? { reason: 'runtime_unknown', clientKind: 'runtime_unknown', until: this.nextRuntimeChange } : await this.browserSuppression(tx, now);
|
|
|
|
|
if (attention) { await suppress(attention); return; }
|
|
|
|
|
if ((state.latestUserMessageAt && state.latestUserMessageAt.getTime() + PUSH_POLICY.promptGraceMs > now)
|
|
|
|
|
|| (state.localPromptAt && state.localPromptAt.getTime() + PUSH_POLICY.promptGraceMs > now)) {
|
|
|
|
|
await suppress({ reason: 'prompt_grace', clientKind: 'system' }); return;
|
|
|
|
|
await suppress({ reason: 'prompt_grace', clientKind: 'system', until: Math.max(state.latestUserMessageAt?.getTime() || 0, state.localPromptAt?.getTime() || 0) + PUSH_POLICY.promptGraceMs }); return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (row.attempts >= MAX_ATTEMPTS) { await tx.agentPushDelivery.update({ where: { id }, data: { status: 'failed', outcome: 'attempt_limit' } }); return; }
|
|
|
|
@@ -345,32 +392,45 @@ export class AgentPushCoordinator {
|
|
|
|
|
await this.options.db.$transaction(async tx => {
|
|
|
|
|
const pending = result.outcome === 'retry' && claim.row.attempts + 1 < MAX_ATTEMPTS;
|
|
|
|
|
await tx.agentPushDelivery.updateMany({ where: { id, status: 'intent' }, data: { status: pending ? 'pending' : result.outcome === 'retry' ? 'failed' : result.outcome,
|
|
|
|
|
outcome: result.outcome, ...(pending ? { dueAt: date(this.now() + (result.retryAfterMs ?? Math.min(60_000, 2_000 * 2 ** claim.row.attempts))) } : {}) } });
|
|
|
|
|
outcome: result.outcome, ...(pending ? { lastSuppressionReason: null, dueAt: date(this.now() + (result.retryAfterMs ?? Math.min(60_000, 2_000 * 2 ** claim.row.attempts))) } : {}) } });
|
|
|
|
|
if (result.outcome === 'invalid') await tx.agentPushInstallation.updateMany({ where: { id: claim.row.installationId, version: claim.row.installationVersion, ownerEpoch: this.epoch }, data: { enabled: false } });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
async tick() {
|
|
|
|
|
if (this.running || this.stopped || !this.options.config.adminToken) return;
|
|
|
|
|
this.running = true;
|
|
|
|
|
this.dirty = false;
|
|
|
|
|
const reconsider = this.reconsider; this.reconsider = false;
|
|
|
|
|
let retryAt: number | undefined;
|
|
|
|
|
let acquired = false;
|
|
|
|
|
try {
|
|
|
|
|
await this.initialize(); if (!await this.acquire()) return;
|
|
|
|
|
await this.initialize();
|
|
|
|
|
acquired = await this.acquire();
|
|
|
|
|
if (!acquired) { retryAt = this.now() + 30_000; return; }
|
|
|
|
|
// Only the lease holder recovers expired intents. Never resend a potentially accepted provider request.
|
|
|
|
|
await this.options.db.agentPushDelivery.updateMany({ where: { status: 'intent', updatedAt: { lt: date(this.now() - 30_000) } }, data: { status: 'ambiguous', outcome: 'interrupted_send' } });
|
|
|
|
|
let more = true;
|
|
|
|
|
for (let i = 0; i < 10 && more; i++) { if (!await this.acquire()) return; more = await this.observe(); }
|
|
|
|
|
if (more) return; // Current native read/prompt/presence must be caught up before dispatch.
|
|
|
|
|
for (let i = 0; i < 10 && more; i++) { if (!await this.acquire()) { retryAt = this.now() + 30_000; return; } more = await this.observe(); }
|
|
|
|
|
if (more) { retryAt = this.now() + 100; return; } // Catch up before dispatch.
|
|
|
|
|
if (reconsider) await this.options.db.agentPushDelivery.updateMany({ where: { status: 'pending', lastSuppressionReason: { not: null }, dueAt: { gt: date(this.now()) } }, data: { dueAt: date(this.now()) } });
|
|
|
|
|
const pending = await this.options.db.agentPushDelivery.findMany({ where: { status: 'pending', dueAt: { lte: date(this.now()) } }, orderBy: [{ priority: 'desc' }, { dueAt: 'asc' }], take: 20, select: { id: true } });
|
|
|
|
|
for (const row of pending) {
|
|
|
|
|
if (this.stopped || !await this.acquire()) break;
|
|
|
|
|
if (this.stopped) break;
|
|
|
|
|
if (!await this.acquire()) { retryAt = this.now() + 30_000; break; }
|
|
|
|
|
// Fetch fresh runtime native presence/read/latest prompt before EACH provider boundary.
|
|
|
|
|
if (await this.observe()) break;
|
|
|
|
|
await this.dispatch(row.id);
|
|
|
|
|
}
|
|
|
|
|
await this.options.db.agentPushDelivery.updateMany({ where: { status: 'pending', expiresAt: { lte: date(this.now()) } }, data: { status: 'cancelled', outcome: 'expired' } });
|
|
|
|
|
await this.options.db.agentPushPresence.deleteMany({ where: { expiresAt: { lt: date(this.now() - 86_400_000) } } });
|
|
|
|
|
await this.options.db.agentPushConnection.deleteMany({ where: { expiresAt: { lt: date(this.now()) } } });
|
|
|
|
|
await this.options.db.agentPushDelivery.deleteMany({ where: { status: { notIn: activeStatuses }, updatedAt: { lt: date(this.now() - 7 * 86_400_000) } } });
|
|
|
|
|
if (this.now() >= this.nextMaintenance) {
|
|
|
|
|
await this.options.db.agentPushPresence.deleteMany({ where: { expiresAt: { lt: date(this.now() - 86_400_000) } } });
|
|
|
|
|
await this.options.db.agentPushConnection.deleteMany({ where: { expiresAt: { lt: date(this.now()) } } });
|
|
|
|
|
await this.options.db.agentPushDelivery.deleteMany({ where: { status: { notIn: activeStatuses }, updatedAt: { lt: date(this.now() - 7 * 86_400_000) } } });
|
|
|
|
|
this.nextMaintenance = this.now() + 3_600_000;
|
|
|
|
|
}
|
|
|
|
|
this.failures = 0;
|
|
|
|
|
} catch {
|
|
|
|
|
retryAt = this.now() + Math.min(30_000, 1_000 * 2 ** Math.min(this.failures++, 5));
|
|
|
|
|
// An unavailable source, DB, malformed contract or clock uncertainty blocks dispatch without leaking payloads.
|
|
|
|
|
try {
|
|
|
|
|
await this.options.db.$transaction(async tx => {
|
|
|
|
@@ -380,8 +440,14 @@ export class AgentPushCoordinator {
|
|
|
|
|
});
|
|
|
|
|
} catch { /* Retry on next loop. */ }
|
|
|
|
|
} finally {
|
|
|
|
|
try { await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { lockOwner: null, lockUntil: null } }); } catch { /* Lease expiry recovers. */ }
|
|
|
|
|
try { if (acquired) await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { lockOwner: null, lockUntil: null } }); } catch { /* Lease expiry recovers. */ }
|
|
|
|
|
if (retryAt) this.reconsider ||= reconsider;
|
|
|
|
|
this.running = false;
|
|
|
|
|
if (this.enabled && !this.stopped) {
|
|
|
|
|
if (retryAt) this.schedule(retryAt);
|
|
|
|
|
else if (this.dirty) this.schedule(this.now() + 100);
|
|
|
|
|
else try { await this.schedulePending(); } catch { this.schedule(this.now() + 30_000); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -390,6 +456,7 @@ export async function registerPushRoutes(app: FastifyInstance, db: PrismaClient,
|
|
|
|
|
overrides?: Partial<Options>): Promise<AgentPushCoordinator> {
|
|
|
|
|
const config = overrides?.config || loadPushConfig();
|
|
|
|
|
const coordinator = new AgentPushCoordinator({ db, config, broadcast,
|
|
|
|
|
subscribe: changed => subscribeNotificationEvents(upstream, changed),
|
|
|
|
|
context: async (after, sourceId) => {
|
|
|
|
|
if (!upstream.apiKey) throw new Error('Runtime unavailable');
|
|
|
|
|
// A replaced/restored runtime can reject the old cursor before returning its identity.
|
|
|
|
|