Phase 2 reactions v2 priority and cooldown

This commit is contained in:
William Valentin
2026-02-25 10:36:56 -08:00
parent e4ee6acce8
commit 7b170cff4d
12 changed files with 417 additions and 25 deletions
+1 -1
View File
@@ -5,5 +5,5 @@ export { HeartbeatMonitor, parseInterval } from './heartbeat.js';
export type { HeartbeatResult, HeartbeatDeps, CheckResult } from './heartbeat.js';
export { buildPresetCronJobs } from './presets.js';
export { MinioSyncScheduler } from './minioSync.js';
export { matchReactionPrompt } from './reactions.js';
export { matchReactionPrompt, resolveReactionDecision } from './reactions.js';
export type { MinioSyncSchedulerDeps } from './minioSync.js';
+101
View File
@@ -10,6 +10,9 @@ function makeRule(overrides: Partial<AutomationReactionConfig> & Pick<Automation
on: overrides.on ?? ['gmail'],
filter: overrides.filter,
run: overrides.run,
priority: overrides.priority ?? 100,
cooldown_ms: overrides.cooldown_ms ?? 0,
stop_on_match: overrides.stop_on_match ?? true,
};
}
@@ -90,4 +93,102 @@ describe('matchReactionPrompt', () => {
expect(result).toBeNull();
});
it('prefers higher priority matches with deterministic ordering', () => {
const rules: AutomationReactionConfig[] = [
makeRule({
name: 'low-priority',
on: ['gmail'],
filter: { contains: 'update' },
run: 'low',
priority: 10,
}),
makeRule({
name: 'high-priority',
on: ['gmail'],
filter: { contains: 'update' },
run: 'high',
priority: 200,
}),
];
const result = matchReactionPrompt(rules, {
channel: 'gmail',
senderId: 'watcher',
text: 'update: status',
});
expect(result).toEqual({ name: 'high-priority', prompt: 'high' });
});
it('skips cooldowned matches and falls back to next candidate', () => {
const cooldowns = new Map<string, number>();
const rules: AutomationReactionConfig[] = [
makeRule({
name: 'cooldowned',
on: ['gmail'],
filter: { contains: 'alert' },
run: 'cooldown',
priority: 200,
cooldown_ms: 1000,
}),
makeRule({
name: 'fallback',
on: ['gmail'],
filter: { contains: 'alert' },
run: 'fallback',
priority: 50,
}),
];
const first = matchReactionPrompt(rules, {
channel: 'gmail',
senderId: 'watcher',
text: 'alert: first',
}, {
now: 1000,
cooldownStore: cooldowns,
cooldownScope: 'gmail:watcher',
});
expect(first).toEqual({ name: 'cooldowned', prompt: 'cooldown' });
const second = matchReactionPrompt(rules, {
channel: 'gmail',
senderId: 'watcher',
text: 'alert: second',
}, {
now: 1500,
cooldownStore: cooldowns,
cooldownScope: 'gmail:watcher',
});
expect(second).toEqual({ name: 'fallback', prompt: 'fallback' });
});
it('allows non-blocking matches to yield to lower-priority stop rules', () => {
const rules: AutomationReactionConfig[] = [
makeRule({
name: 'non-blocking',
on: ['gmail'],
filter: { contains: 'ping' },
run: 'soft',
priority: 200,
stop_on_match: false,
}),
makeRule({
name: 'blocking',
on: ['gmail'],
filter: { contains: 'ping' },
run: 'hard',
priority: 100,
}),
];
const result = matchReactionPrompt(rules, {
channel: 'gmail',
senderId: 'watcher',
text: 'ping',
});
expect(result).toEqual({ name: 'blocking', prompt: 'hard' });
});
});
+97 -9
View File
@@ -12,6 +12,18 @@ export interface ReactionMatchResult {
prompt: string;
}
export interface ReactionMatchOptions {
now?: number;
cooldownStore?: Map<string, number>;
cooldownScope?: string;
}
export interface ReactionDecision {
match: ReactionMatchResult | null;
matchedRule?: AutomationReactionConfig;
cooldownSkipped: number;
}
function getNestedValue(record: Record<string, unknown>, path: string): unknown {
const segments = path.split('.').filter(Boolean);
let current: unknown = record;
@@ -104,19 +116,95 @@ function ruleMatches(rule: AutomationReactionConfig, event: ReactionEvent): bool
return true;
}
/** Find the first matching reaction rule and render its run template. */
export function matchReactionPrompt(
function buildCooldownKey(scope: string, ruleName: string): string {
return `${scope}:${ruleName}`;
}
/**
* Resolve the best matching reaction rule using deterministic priority and cooldown semantics.
* - Higher priority wins (default priority = 100).
* - Ties break by config order.
* - stop_on_match=false means "non-blocking": it only wins if no stop_on_match=true rules match.
*/
export function resolveReactionDecision(
reactions: AutomationReactionConfig[],
event: ReactionEvent,
): ReactionMatchResult | null {
for (const rule of reactions) {
options?: ReactionMatchOptions,
): ReactionDecision {
const now = options?.now ?? Date.now();
const cooldownScope = options?.cooldownScope ?? `${event.channel}:${event.senderId}`;
const cooldownStore = options?.cooldownStore;
const candidates: Array<{ rule: AutomationReactionConfig; index: number }> = [];
let cooldownSkipped = 0;
for (const [index, rule] of reactions.entries()) {
if (!ruleMatches(rule, event)) {
continue;
}
return {
name: rule.name,
prompt: renderTemplate(rule.run, event),
};
if (cooldownStore && (rule.cooldown_ms ?? 0) > 0) {
const key = buildCooldownKey(cooldownScope, rule.name);
const lastMatchedAt = cooldownStore.get(key);
if (typeof lastMatchedAt === 'number' && now - lastMatchedAt < rule.cooldown_ms) {
cooldownSkipped += 1;
continue;
}
}
candidates.push({ rule, index });
}
return null;
if (candidates.length === 0) {
return { match: null, cooldownSkipped };
}
candidates.sort((a, b) => {
const priorityA = Number.isFinite(a.rule.priority) ? a.rule.priority : 100;
const priorityB = Number.isFinite(b.rule.priority) ? b.rule.priority : 100;
if (priorityA !== priorityB) {
return priorityB - priorityA;
}
return a.index - b.index;
});
let fallback: { rule: AutomationReactionConfig; index: number } | undefined;
let selected: { rule: AutomationReactionConfig; index: number } | undefined;
for (const candidate of candidates) {
if (!fallback) {
fallback = candidate;
}
if (candidate.rule.stop_on_match !== false) {
selected = candidate;
break;
}
}
const chosen = selected ?? fallback;
if (!chosen) {
return { match: null, cooldownSkipped };
}
if (cooldownStore && (chosen.rule.cooldown_ms ?? 0) > 0) {
const key = buildCooldownKey(cooldownScope, chosen.rule.name);
cooldownStore.set(key, now);
}
return {
match: {
name: chosen.rule.name,
prompt: renderTemplate(chosen.rule.run, event),
},
matchedRule: chosen.rule,
cooldownSkipped,
};
}
/** Find the best matching reaction rule and render its run template. */
export function matchReactionPrompt(
reactions: AutomationReactionConfig[],
event: ReactionEvent,
options?: ReactionMatchOptions,
): ReactionMatchResult | null {
return resolveReactionDecision(reactions, event, options).match;
}