Phase 2 reactions v2 priority and cooldown
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user