mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-08-01 04:01:01 +00:00
feat(automationPlugin): enhance error handling and improve rate limiting for automation rules
This commit is contained in:
parent
997d497824
commit
55b27728dc
5 changed files with 165 additions and 100 deletions
|
|
@ -52,8 +52,15 @@ export const ActionDisplayDialog: IAutomationAction = {
|
|||
name: 'Display Dialog',
|
||||
execute: async (ctx, event, value) => {
|
||||
if (!value) return;
|
||||
const escapedValue = value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
await ctx.plugin.openDialog({
|
||||
htmlContent: `<p>${value}</p>`,
|
||||
htmlContent: `<p>${escapedValue}</p>`,
|
||||
buttons: [{ label: 'OK', onClick: () => {} }],
|
||||
});
|
||||
ctx.plugin.log.info(`[Automation] Action: Displayed dialog "${value}"`);
|
||||
|
|
@ -66,11 +73,16 @@ export const ActionWebhook: IAutomationAction = {
|
|||
execute: async (ctx, event, value) => {
|
||||
if (!value) return;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
await fetch(value, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(event),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
ctx.plugin.log.info(`[Automation] Action: Webhook sent to "${value}"`);
|
||||
} catch (e) {
|
||||
ctx.plugin.log.error(`[Automation] Webhook failed: ${e}`);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ import * as Actions from './actions';
|
|||
import { DataCache } from './data-cache';
|
||||
|
||||
export class AutomationManager {
|
||||
private static readonly RATE_LIMIT_MAX = 5;
|
||||
private static readonly RATE_LIMIT_WINDOW_MS = 1000;
|
||||
private static readonly CHECK_INTERVAL_MS = 10000;
|
||||
private static readonly TIME_RULE_COOLDOWN_MS = 60000;
|
||||
|
||||
private ruleRegistry: RuleRegistry;
|
||||
private conditionEvaluator: ConditionEvaluator;
|
||||
private actionExecutor: ActionExecutor;
|
||||
|
|
@ -29,7 +34,10 @@ export class AutomationManager {
|
|||
this.ruleRegistry = new RuleRegistry(plugin);
|
||||
this.conditionEvaluator = new ConditionEvaluator(plugin, globalRegistry, this.dataCache);
|
||||
this.actionExecutor = new ActionExecutor(plugin, globalRegistry, this.dataCache);
|
||||
this.rateLimiter = new RateLimiter(5, 1000); // 5 executions per second
|
||||
this.rateLimiter = new RateLimiter(
|
||||
AutomationManager.RATE_LIMIT_MAX,
|
||||
AutomationManager.RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
this.initTimeCheck();
|
||||
}
|
||||
|
||||
|
|
@ -54,10 +62,9 @@ export class AutomationManager {
|
|||
}
|
||||
|
||||
private initTimeCheck() {
|
||||
// Check every 10 seconds
|
||||
this.clearTimeCheck = lazySetInterval(() => {
|
||||
this.checkTimeBasedRules();
|
||||
}, 10000);
|
||||
}, AutomationManager.CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
|
@ -68,38 +75,49 @@ export class AutomationManager {
|
|||
}
|
||||
|
||||
private async checkTimeBasedRules() {
|
||||
const rules = await this.ruleRegistry.getEnabledRules();
|
||||
const now = new Date();
|
||||
const currentHours = now.getHours();
|
||||
const currentMinutes = now.getMinutes();
|
||||
const currentTimeStr = `${currentHours.toString().padStart(2, '0')}:${currentMinutes.toString().padStart(2, '0')}`;
|
||||
try {
|
||||
const rules = await this.ruleRegistry.getEnabledRules();
|
||||
const now = new Date();
|
||||
const currentHours = now.getHours();
|
||||
const currentMinutes = now.getMinutes();
|
||||
const currentTimeStr = `${currentHours.toString().padStart(2, '0')}:${currentMinutes.toString().padStart(2, '0')}`;
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.trigger.type !== 'timeBased' || !rule.trigger.value) continue;
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
if (rule.trigger.type !== 'timeBased' || !rule.trigger.value) continue;
|
||||
|
||||
if (rule.trigger.value === currentTimeStr) {
|
||||
const lastRun = this.lastExecutionTimes.get(rule.id) || 0;
|
||||
// Prevent multiple executions within the same minute
|
||||
if (now.getTime() - lastRun < 60000) continue;
|
||||
if (rule.trigger.value === currentTimeStr) {
|
||||
const lastRun = this.lastExecutionTimes.get(rule.id) || 0;
|
||||
// Prevent multiple executions within the same minute
|
||||
if (now.getTime() - lastRun < AutomationManager.TIME_RULE_COOLDOWN_MS) continue;
|
||||
|
||||
// Check conditions (even for time-based rules, though most conditions require a task)
|
||||
// We pass a dummy event. The evaluator must handle missing tasks gracefully.
|
||||
const event: TaskEvent = {
|
||||
type: 'timeBased',
|
||||
task: undefined,
|
||||
};
|
||||
// Check conditions (even for time-based rules, though most conditions require a task)
|
||||
// We pass a dummy event. The evaluator must handle missing tasks gracefully.
|
||||
const event: TaskEvent = {
|
||||
type: 'timeBased',
|
||||
task: undefined,
|
||||
};
|
||||
|
||||
const matches = await this.conditionEvaluator.allConditionsMatch(rule.conditions, event);
|
||||
if (!matches) continue;
|
||||
const matches = await this.conditionEvaluator.allConditionsMatch(
|
||||
rule.conditions,
|
||||
event,
|
||||
);
|
||||
if (!matches) continue;
|
||||
|
||||
this.lastExecutionTimes.set(rule.id, now.getTime());
|
||||
this.plugin.log.info(`[Automation] Time-based rule matched: ${rule.name}`);
|
||||
this.lastExecutionTimes.set(rule.id, now.getTime());
|
||||
this.plugin.log.info(`[Automation] Time-based rule matched: ${rule.name}`);
|
||||
|
||||
// Execute actions
|
||||
await this.actionExecutor.executeAll(rule.actions, {
|
||||
type: 'timeBased',
|
||||
});
|
||||
// Execute actions
|
||||
await this.actionExecutor.executeAll(rule.actions, {
|
||||
type: 'timeBased',
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.plugin.log.error(`[Automation] Error processing time-based rule ${rule.name}: ${e}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.plugin.log.error(`[Automation] Error in checkTimeBasedRules: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -110,57 +128,65 @@ export class AutomationManager {
|
|||
}
|
||||
this.plugin.log.info(`[Automation] Event received: ${event.type}`, event.task.title);
|
||||
|
||||
const rules = await this.ruleRegistry.getEnabledRules();
|
||||
try {
|
||||
const rules = await this.ruleRegistry.getEnabledRules();
|
||||
|
||||
for (const rule of rules) {
|
||||
const triggerImpl = globalRegistry.getTrigger(rule.trigger.type);
|
||||
// If trigger not found or doesn't match, skip
|
||||
if (!triggerImpl || !triggerImpl.matches(event, rule.trigger.value)) continue;
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
const triggerImpl = globalRegistry.getTrigger(rule.trigger.type);
|
||||
// If trigger not found or doesn't match, skip
|
||||
if (!triggerImpl || !triggerImpl.matches(event, rule.trigger.value)) continue;
|
||||
|
||||
const matches = await this.conditionEvaluator.allConditionsMatch(rule.conditions, event);
|
||||
if (!matches) continue;
|
||||
const matches = await this.conditionEvaluator.allConditionsMatch(rule.conditions, event);
|
||||
if (!matches) continue;
|
||||
|
||||
// Check rate limit
|
||||
if (!this.rateLimiter.check(rule.id)) {
|
||||
this.plugin.log.warn(`[Automation] Rate limit exceeded for rule: ${rule.name}`);
|
||||
// Check rate limit
|
||||
if (!this.rateLimiter.check(rule.id)) {
|
||||
this.plugin.log.warn(`[Automation] Rate limit exceeded for rule: ${rule.name}`);
|
||||
|
||||
if (!this.pendingDialogs.has(rule.id)) {
|
||||
this.pendingDialogs.add(rule.id);
|
||||
if (!this.pendingDialogs.has(rule.id)) {
|
||||
this.pendingDialogs.add(rule.id);
|
||||
|
||||
const dialogCfg: DialogCfg = {
|
||||
htmlContent: `
|
||||
const dialogCfg: DialogCfg = {
|
||||
htmlContent: `
|
||||
<h3>High Automation Activity Detected</h3>
|
||||
<p>The rule <strong>"${rule.name}"</strong> is triggering too frequently (infinite loop protection).</p>
|
||||
<p>Do you want to disable this rule or continue execution?</p>
|
||||
`,
|
||||
buttons: [
|
||||
{
|
||||
label: 'Disable Rule',
|
||||
color: 'warn',
|
||||
onClick: async () => {
|
||||
await this.ruleRegistry.toggleRuleStatus(rule.id, false);
|
||||
this.plugin.showSnack({ msg: `Rule "${rule.name}" disabled`, type: 'INFO' });
|
||||
this.pendingDialogs.delete(rule.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Continue',
|
||||
color: 'primary',
|
||||
onClick: () => {
|
||||
this.rateLimiter.reset(rule.id);
|
||||
this.pendingDialogs.delete(rule.id);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
buttons: [
|
||||
{
|
||||
label: 'Disable Rule',
|
||||
color: 'warn',
|
||||
onClick: async () => {
|
||||
await this.ruleRegistry.toggleRuleStatus(rule.id, false);
|
||||
this.plugin.showSnack({ msg: `Rule "${rule.name}" disabled`, type: 'INFO' });
|
||||
this.pendingDialogs.delete(rule.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Continue',
|
||||
color: 'primary',
|
||||
onClick: () => {
|
||||
this.rateLimiter.reset(rule.id);
|
||||
this.pendingDialogs.delete(rule.id);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await this.plugin.openDialog(dialogCfg);
|
||||
await this.plugin.openDialog(dialogCfg);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
this.plugin.log.info(`[Automation] Rule matched: ${rule.name}`);
|
||||
await this.actionExecutor.executeAll(rule.actions, event);
|
||||
} catch (e) {
|
||||
this.plugin.log.error(`[Automation] Error processing rule ${rule.name}: ${e}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
this.plugin.log.info(`[Automation] Rule matched: ${rule.name}`);
|
||||
await this.actionExecutor.executeAll(rule.actions, event);
|
||||
} catch (e) {
|
||||
this.plugin.log.error(`[Automation] Error in onTaskEvent: ${e}`);
|
||||
}
|
||||
}
|
||||
getRegistry(): RuleRegistry {
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ interface CacheItem<T> {
|
|||
export class DataCache {
|
||||
private projectsCache: CacheItem<any[]> | null = null;
|
||||
private tagsCache: CacheItem<any[]> | null = null;
|
||||
private readonly TTL = 60000; // 60 seconds
|
||||
private static readonly CACHE_TTL_MS = 60000;
|
||||
|
||||
constructor(private plugin: PluginAPI) {}
|
||||
|
||||
async getProjects(): Promise<any[]> {
|
||||
const now = Date.now();
|
||||
if (this.projectsCache && now - this.projectsCache.timestamp < this.TTL) {
|
||||
if (this.projectsCache && now - this.projectsCache.timestamp < DataCache.CACHE_TTL_MS) {
|
||||
return this.projectsCache.data;
|
||||
}
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ export class DataCache {
|
|||
|
||||
async getTags(): Promise<any[]> {
|
||||
const now = Date.now();
|
||||
if (this.tagsCache && now - this.tagsCache.timestamp < this.TTL) {
|
||||
if (this.tagsCache && now - this.tagsCache.timestamp < DataCache.CACHE_TTL_MS) {
|
||||
return this.tagsCache.data;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ describe('RuleRegistry', () => {
|
|||
// Since loadRules is called in constructor without await, we need to wait for promises.
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(registry.getRules()).toEqual([]);
|
||||
expect(await registry.getRules()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should load existing rules', async () => {
|
||||
|
|
@ -43,7 +43,7 @@ describe('RuleRegistry', () => {
|
|||
registry = new RuleRegistry(mockPlugin);
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(registry.getRules()).toEqual(rules);
|
||||
expect(await registry.getRules()).toEqual(rules);
|
||||
});
|
||||
|
||||
it('should add rule and persist', async () => {
|
||||
|
|
@ -61,7 +61,7 @@ describe('RuleRegistry', () => {
|
|||
|
||||
await registry.addOrUpdateRule(newRule);
|
||||
|
||||
expect(registry.getRules()).toContainEqual(newRule);
|
||||
expect(await registry.getRules()).toContainEqual(newRule);
|
||||
expect(mockPlugin.persistDataSynced).toHaveBeenCalledWith(JSON.stringify([newRule]));
|
||||
});
|
||||
|
||||
|
|
@ -81,8 +81,9 @@ describe('RuleRegistry', () => {
|
|||
const updatedRule = { ...rule, name: 'Updated Rule' };
|
||||
await registry.addOrUpdateRule(updatedRule);
|
||||
|
||||
expect(registry.getRules()).toHaveLength(1);
|
||||
expect(registry.getRules()[0].name).toBe('Updated Rule');
|
||||
const rules = await registry.getRules();
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0].name).toBe('Updated Rule');
|
||||
});
|
||||
|
||||
it('should delete rule', async () => {
|
||||
|
|
@ -99,7 +100,7 @@ describe('RuleRegistry', () => {
|
|||
await new Promise(process.nextTick);
|
||||
|
||||
await registry.deleteRule('r1');
|
||||
expect(registry.getRules()).toHaveLength(0);
|
||||
expect(await registry.getRules()).toHaveLength(0);
|
||||
expect(mockPlugin.persistDataSynced).toHaveBeenCalledWith(JSON.stringify([]));
|
||||
});
|
||||
|
||||
|
|
@ -126,7 +127,8 @@ describe('RuleRegistry', () => {
|
|||
registry = new RuleRegistry(mockPlugin);
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(registry.getEnabledRules()).toHaveLength(1);
|
||||
expect(registry.getEnabledRules()[0].id).toBe('r1');
|
||||
const enabledRules = await registry.getEnabledRules();
|
||||
expect(enabledRules).toHaveLength(1);
|
||||
expect(enabledRules[0].id).toBe('r1');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export class RuleRegistry {
|
|||
private rules: AutomationRule[] = [];
|
||||
private plugin: PluginAPI;
|
||||
private initPromise: Promise<void>;
|
||||
private saveQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(plugin: PluginAPI) {
|
||||
this.plugin = plugin;
|
||||
|
|
@ -18,7 +19,7 @@ export class RuleRegistry {
|
|||
this.rules = JSON.parse(data);
|
||||
} else {
|
||||
this.initDefaultRules();
|
||||
this.saveRules();
|
||||
await this.saveRules();
|
||||
}
|
||||
} catch (e) {
|
||||
this.plugin.log.error('Failed to load rules', e);
|
||||
|
|
@ -31,12 +32,15 @@ export class RuleRegistry {
|
|||
this.rules = [];
|
||||
}
|
||||
|
||||
async saveRules() {
|
||||
try {
|
||||
await this.plugin.persistDataSynced(JSON.stringify(this.rules));
|
||||
} catch (e) {
|
||||
this.plugin.log.error('Failed to save rules', e);
|
||||
}
|
||||
private async saveRules() {
|
||||
this.saveQueue = this.saveQueue.then(async () => {
|
||||
try {
|
||||
await this.plugin.persistDataSynced(JSON.stringify(this.rules));
|
||||
} catch (e) {
|
||||
this.plugin.log.error('Failed to save rules', e);
|
||||
}
|
||||
});
|
||||
await this.saveQueue;
|
||||
}
|
||||
|
||||
async getRules(): Promise<AutomationRule[]> {
|
||||
|
|
@ -51,27 +55,48 @@ export class RuleRegistry {
|
|||
|
||||
async addOrUpdateRule(rule: AutomationRule) {
|
||||
await this.initPromise;
|
||||
const index = this.rules.findIndex((r) => r.id === rule.id);
|
||||
if (index !== -1) {
|
||||
this.rules[index] = rule;
|
||||
} else {
|
||||
this.rules.push(rule);
|
||||
}
|
||||
await this.saveRules();
|
||||
this.saveQueue = this.saveQueue.then(async () => {
|
||||
const index = this.rules.findIndex((r) => r.id === rule.id);
|
||||
if (index !== -1) {
|
||||
this.rules[index] = rule;
|
||||
} else {
|
||||
this.rules.push(rule);
|
||||
}
|
||||
try {
|
||||
await this.plugin.persistDataSynced(JSON.stringify(this.rules));
|
||||
} catch (e) {
|
||||
this.plugin.log.error('Failed to save rules', e);
|
||||
}
|
||||
});
|
||||
await this.saveQueue;
|
||||
}
|
||||
|
||||
async deleteRule(ruleId: string) {
|
||||
await this.initPromise;
|
||||
this.rules = this.rules.filter((r) => r.id !== ruleId);
|
||||
await this.saveRules();
|
||||
this.saveQueue = this.saveQueue.then(async () => {
|
||||
this.rules = this.rules.filter((r) => r.id !== ruleId);
|
||||
try {
|
||||
await this.plugin.persistDataSynced(JSON.stringify(this.rules));
|
||||
} catch (e) {
|
||||
this.plugin.log.error('Failed to save rules', e);
|
||||
}
|
||||
});
|
||||
await this.saveQueue;
|
||||
}
|
||||
|
||||
async toggleRuleStatus(ruleId: string, isEnabled: boolean) {
|
||||
await this.initPromise;
|
||||
const rule = this.rules.find((r) => r.id === ruleId);
|
||||
if (rule) {
|
||||
rule.isEnabled = isEnabled;
|
||||
await this.saveRules();
|
||||
}
|
||||
this.saveQueue = this.saveQueue.then(async () => {
|
||||
const rule = this.rules.find((r) => r.id === ruleId);
|
||||
if (rule) {
|
||||
rule.isEnabled = isEnabled;
|
||||
try {
|
||||
await this.plugin.persistDataSynced(JSON.stringify(this.rules));
|
||||
} catch (e) {
|
||||
this.plugin.log.error('Failed to save rules', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
await this.saveQueue;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue