clawmatrix 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -21
- package/cli/bin/clawmatrix.mjs +300 -1
- package/package.json +8 -1
- package/src/acp-proxy.ts +122 -50
- package/src/{web.ts → api.ts} +646 -25
- package/src/audit.ts +37 -2
- package/src/auth.ts +5 -10
- package/src/automation.ts +625 -0
- package/src/cluster-service.ts +172 -16
- package/src/compat.ts +103 -0
- package/src/config.ts +75 -27
- package/src/connection.ts +215 -37
- package/src/crypto.ts +72 -5
- package/src/device-info.ts +21 -2
- package/src/file-transfer.ts +3 -2
- package/src/handoff.ts +90 -32
- package/src/health-tracker.ts +91 -356
- package/src/index.ts +421 -13
- package/src/kanban.ts +507 -0
- package/src/knowledge-sync.ts +158 -7
- package/src/local-tools.ts +65 -2
- package/src/log-replication.ts +198 -0
- package/src/model-proxy.ts +152 -60
- package/src/peer-approval.ts +3 -2
- package/src/peer-manager.ts +230 -44
- package/src/retry.ts +81 -0
- package/src/router.ts +152 -104
- package/src/sentinel.ts +85 -51
- package/src/store.ts +578 -0
- package/src/terminal.ts +17 -8
- package/src/tool-proxy.ts +6 -5
- package/src/tools/cluster-events.ts +6 -6
- package/src/tools/cluster-kanban.ts +345 -0
- package/src/tools/cluster-peers.ts +1 -1
- package/src/tools/cluster-query.ts +145 -0
- package/src/types.ts +95 -9
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AutomationManager — rule-based automation triggers.
|
|
3
|
+
*
|
|
4
|
+
* Rules are stored in `.clawmatrix/automations.json` and can be synced
|
|
5
|
+
* across devices via CRDT knowledge sync. When an ingested event matches
|
|
6
|
+
* a rule's trigger, an action is automatically dispatched.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
10
|
+
import { watch, type FSWatcher } from "node:fs";
|
|
11
|
+
import { nanoid } from "nanoid";
|
|
12
|
+
import type { HandoffManager } from "./handoff.ts";
|
|
13
|
+
import type { PeerManager } from "./peer-manager.ts";
|
|
14
|
+
import type { IngestedEvent } from "./types.ts";
|
|
15
|
+
import { debug } from "./debug.ts";
|
|
16
|
+
import type { Store } from "./store.ts";
|
|
17
|
+
|
|
18
|
+
const MAX_EXECUTIONS = 100;
|
|
19
|
+
const DEFAULT_COOLDOWN_MS = 30_000;
|
|
20
|
+
const DEFAULT_RETRY_DELAY_MS = 5_000;
|
|
21
|
+
const MAX_CONCURRENT = 3;
|
|
22
|
+
const FILE_WATCH_DEBOUNCE_MS = 1_000;
|
|
23
|
+
|
|
24
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface AutomationTrigger {
|
|
27
|
+
type: "event" | "schedule";
|
|
28
|
+
source?: string;
|
|
29
|
+
eventType?: string;
|
|
30
|
+
intervalMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AutomationCondition {
|
|
34
|
+
path: string;
|
|
35
|
+
operator: "exists" | "not_exists" | "eq" | "ne" | "contains" | "gt" | "gte" | "lt" | "lte";
|
|
36
|
+
value?: string | number | boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AutomationHandoffAction {
|
|
40
|
+
type?: "handoff";
|
|
41
|
+
agent: string;
|
|
42
|
+
task: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AutomationToolAction {
|
|
46
|
+
type: "tool";
|
|
47
|
+
node: string;
|
|
48
|
+
tool: string;
|
|
49
|
+
params?: Record<string, unknown>;
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AutomationNotifyAction {
|
|
54
|
+
type: "notify";
|
|
55
|
+
title: string;
|
|
56
|
+
detail?: string;
|
|
57
|
+
progress?: number;
|
|
58
|
+
success?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type AutomationAction =
|
|
62
|
+
| AutomationHandoffAction
|
|
63
|
+
| AutomationToolAction
|
|
64
|
+
| AutomationNotifyAction;
|
|
65
|
+
|
|
66
|
+
export interface AutomationRetryPolicy {
|
|
67
|
+
maxAttempts?: number;
|
|
68
|
+
delayMs?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface AutomationRule {
|
|
72
|
+
id: string;
|
|
73
|
+
name: string;
|
|
74
|
+
enabled: boolean;
|
|
75
|
+
trigger: AutomationTrigger;
|
|
76
|
+
conditions?: AutomationCondition[];
|
|
77
|
+
action: AutomationAction;
|
|
78
|
+
notify?: boolean;
|
|
79
|
+
cooldownMs?: number;
|
|
80
|
+
retry?: AutomationRetryPolicy;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface AutomationExecution {
|
|
84
|
+
id: string;
|
|
85
|
+
ruleId: string;
|
|
86
|
+
ruleName: string;
|
|
87
|
+
status: "running" | "success" | "error";
|
|
88
|
+
startedAt: number;
|
|
89
|
+
finishedAt?: number;
|
|
90
|
+
result?: string;
|
|
91
|
+
error?: string;
|
|
92
|
+
attempt: number;
|
|
93
|
+
maxAttempts: number;
|
|
94
|
+
triggerSource?: string;
|
|
95
|
+
triggerType?: string;
|
|
96
|
+
triggerPayload?: string;
|
|
97
|
+
actionType: "handoff" | "tool" | "notify";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface AutomationsFile {
|
|
101
|
+
rules: AutomationRule[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface ToolInvoker {
|
|
105
|
+
invoke(node: string, tool: string, params: Record<string, unknown>, timeout?: number): Promise<Record<string, unknown>>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
function resolvePath(data: Record<string, unknown>, path: string): unknown {
|
|
111
|
+
const parts = path.split(".");
|
|
112
|
+
let current: unknown = data;
|
|
113
|
+
for (const part of parts) {
|
|
114
|
+
if (current == null || typeof current !== "object") return undefined;
|
|
115
|
+
current = (current as Record<string, unknown>)[part];
|
|
116
|
+
}
|
|
117
|
+
return current;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function interpolateTemplate(template: string, data: Record<string, unknown>): string {
|
|
121
|
+
return template.replace(/\$\{([^}]+)\}/g, (_match, expression: string) => {
|
|
122
|
+
const trimmed = expression.trim();
|
|
123
|
+
const path = trimmed.startsWith("data.") || trimmed.startsWith("event.")
|
|
124
|
+
? trimmed
|
|
125
|
+
: `data.${trimmed}`;
|
|
126
|
+
const value = resolvePath(data, path);
|
|
127
|
+
return value != null ? String(value) : "";
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function interpolateUnknown(value: unknown, context: Record<string, unknown>): unknown {
|
|
132
|
+
if (typeof value === "string") return interpolateTemplate(value, context);
|
|
133
|
+
if (Array.isArray(value)) return value.map((item) => interpolateUnknown(item, context));
|
|
134
|
+
if (value && typeof value === "object") {
|
|
135
|
+
return Object.fromEntries(
|
|
136
|
+
Object.entries(value as Record<string, unknown>).map(([key, inner]) => [key, interpolateUnknown(inner, context)]),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function coerceComparable(value: unknown): { number: number | null; text: string } {
|
|
143
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
144
|
+
return { number: value, text: String(value) };
|
|
145
|
+
}
|
|
146
|
+
if (typeof value === "boolean") {
|
|
147
|
+
return { number: Number(value), text: String(value) };
|
|
148
|
+
}
|
|
149
|
+
if (typeof value === "string") {
|
|
150
|
+
const number = Number(value);
|
|
151
|
+
return {
|
|
152
|
+
number: Number.isFinite(number) && value.trim() !== "" ? number : null,
|
|
153
|
+
text: value,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return { number: null, text: value == null ? "" : String(value) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function matchesCondition(condition: AutomationCondition, data: Record<string, unknown>): boolean {
|
|
160
|
+
const actual = resolvePath(data, condition.path);
|
|
161
|
+
const expected = condition.value;
|
|
162
|
+
const actualCmp = coerceComparable(actual);
|
|
163
|
+
const expectedCmp = coerceComparable(expected);
|
|
164
|
+
|
|
165
|
+
switch (condition.operator) {
|
|
166
|
+
case "exists":
|
|
167
|
+
return actual !== undefined;
|
|
168
|
+
case "not_exists":
|
|
169
|
+
return actual === undefined;
|
|
170
|
+
case "eq":
|
|
171
|
+
return actualCmp.number != null && expectedCmp.number != null
|
|
172
|
+
? actualCmp.number === expectedCmp.number
|
|
173
|
+
: actualCmp.text === expectedCmp.text;
|
|
174
|
+
case "ne":
|
|
175
|
+
return actualCmp.number != null && expectedCmp.number != null
|
|
176
|
+
? actualCmp.number !== expectedCmp.number
|
|
177
|
+
: actualCmp.text !== expectedCmp.text;
|
|
178
|
+
case "contains":
|
|
179
|
+
if (Array.isArray(actual)) return actual.includes(expected);
|
|
180
|
+
return actualCmp.text.includes(expectedCmp.text);
|
|
181
|
+
case "gt":
|
|
182
|
+
return actualCmp.number != null && expectedCmp.number != null && actualCmp.number > expectedCmp.number;
|
|
183
|
+
case "gte":
|
|
184
|
+
return actualCmp.number != null && expectedCmp.number != null && actualCmp.number >= expectedCmp.number;
|
|
185
|
+
case "lt":
|
|
186
|
+
return actualCmp.number != null && expectedCmp.number != null && actualCmp.number < expectedCmp.number;
|
|
187
|
+
case "lte":
|
|
188
|
+
return actualCmp.number != null && expectedCmp.number != null && actualCmp.number <= expectedCmp.number;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function sleep(ms: number): Promise<void> {
|
|
193
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function clampInteger(value: number | undefined, fallback: number, min: number, max: number): number {
|
|
197
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
198
|
+
return Math.min(Math.max(Math.trunc(value), min), max);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function normalizeAction(action: AutomationAction): AutomationAction {
|
|
202
|
+
if (!("type" in action) || action.type == null) {
|
|
203
|
+
return { ...action, type: "handoff" } as AutomationHandoffAction;
|
|
204
|
+
}
|
|
205
|
+
return action;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function buildTemplateContext(event?: IngestedEvent, data: Record<string, unknown> = {}): Record<string, unknown> {
|
|
209
|
+
return {
|
|
210
|
+
data,
|
|
211
|
+
event: event
|
|
212
|
+
? {
|
|
213
|
+
id: event.id,
|
|
214
|
+
source: event.source,
|
|
215
|
+
type: event.type,
|
|
216
|
+
ts: event.ts,
|
|
217
|
+
data: event.data,
|
|
218
|
+
}
|
|
219
|
+
: undefined,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function serializeTriggerPayload(event?: IngestedEvent, data: Record<string, unknown> = {}): string | undefined {
|
|
224
|
+
if (!event && Object.keys(data).length === 0) return undefined;
|
|
225
|
+
return JSON.stringify({
|
|
226
|
+
event: event ?? null,
|
|
227
|
+
data,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── AutomationManager ────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
export class AutomationManager {
|
|
234
|
+
private rules: AutomationRule[] = [];
|
|
235
|
+
private executions: AutomationExecution[] = [];
|
|
236
|
+
private scheduleTimers = new Map<string, ReturnType<typeof setInterval>>();
|
|
237
|
+
private lastFired = new Map<string, number>();
|
|
238
|
+
private running = 0;
|
|
239
|
+
private filePath: string;
|
|
240
|
+
private watcher: FSWatcher | null = null;
|
|
241
|
+
private watchDebounce: ReturnType<typeof setTimeout> | null = null;
|
|
242
|
+
private handoffManager: HandoffManager;
|
|
243
|
+
private peerManager: PeerManager;
|
|
244
|
+
private nodeId: string;
|
|
245
|
+
private store: Store | null = null;
|
|
246
|
+
private toolInvoker: ToolInvoker | null = null;
|
|
247
|
+
|
|
248
|
+
constructor(filePath: string, handoffManager: HandoffManager, peerManager: PeerManager, nodeId: string) {
|
|
249
|
+
this.filePath = filePath;
|
|
250
|
+
this.handoffManager = handoffManager;
|
|
251
|
+
this.peerManager = peerManager;
|
|
252
|
+
this.nodeId = nodeId;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async start(): Promise<void> {
|
|
256
|
+
await this.loadRules();
|
|
257
|
+
this.startScheduleTimers();
|
|
258
|
+
this.startFileWatch();
|
|
259
|
+
debug("automation", `started with ${this.rules.length} rule(s)`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
stop(): void {
|
|
263
|
+
this.stopScheduleTimers();
|
|
264
|
+
if (this.watcher) {
|
|
265
|
+
this.watcher.close();
|
|
266
|
+
this.watcher = null;
|
|
267
|
+
}
|
|
268
|
+
if (this.watchDebounce) {
|
|
269
|
+
clearTimeout(this.watchDebounce);
|
|
270
|
+
this.watchDebounce = null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
setStore(store: Store) {
|
|
275
|
+
this.store = store;
|
|
276
|
+
this.executions = store.queryAutomationExecutions(MAX_EXECUTIONS)
|
|
277
|
+
.reverse()
|
|
278
|
+
.map((row) => ({
|
|
279
|
+
id: row.id,
|
|
280
|
+
ruleId: row.rule_id,
|
|
281
|
+
ruleName: row.rule_name,
|
|
282
|
+
status: row.status as AutomationExecution["status"],
|
|
283
|
+
startedAt: row.started_at,
|
|
284
|
+
finishedAt: row.finished_at ?? undefined,
|
|
285
|
+
result: row.result ?? undefined,
|
|
286
|
+
error: row.error ?? undefined,
|
|
287
|
+
attempt: row.attempt,
|
|
288
|
+
maxAttempts: row.max_attempts,
|
|
289
|
+
triggerSource: row.trigger_source ?? undefined,
|
|
290
|
+
triggerType: row.trigger_type ?? undefined,
|
|
291
|
+
triggerPayload: row.trigger_payload ?? undefined,
|
|
292
|
+
actionType: this.resolveActionType(row.rule_id),
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
setToolInvoker(toolInvoker: ToolInvoker) {
|
|
297
|
+
this.toolInvoker = toolInvoker;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
onEventIngested(event: IngestedEvent): void {
|
|
301
|
+
for (const rule of this.rules) {
|
|
302
|
+
if (!rule.enabled) continue;
|
|
303
|
+
if (rule.trigger.type !== "event") continue;
|
|
304
|
+
if (!this.matchesEvent(rule, event)) continue;
|
|
305
|
+
if (this.isOnCooldown(rule)) continue;
|
|
306
|
+
|
|
307
|
+
void this.executeRule(rule, event, event.data);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
getRules(): AutomationRule[] {
|
|
312
|
+
return this.rules;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
getExecutions(limit = 50): AutomationExecution[] {
|
|
316
|
+
return this.executions.slice(-limit).reverse();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async saveRules(rules: AutomationRule[]): Promise<void> {
|
|
320
|
+
const normalized = rules.map((rule) => ({ ...rule, action: normalizeAction(rule.action) }));
|
|
321
|
+
const content: AutomationsFile = { rules: normalized };
|
|
322
|
+
await writeFile(this.filePath, JSON.stringify(content, null, 2), "utf-8");
|
|
323
|
+
this.stopScheduleTimers();
|
|
324
|
+
this.rules = normalized;
|
|
325
|
+
this.startScheduleTimers();
|
|
326
|
+
debug("automation", `saved ${normalized.length} rule(s)`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async runRuleById(ruleId: string, event?: IngestedEvent): Promise<AutomationExecution> {
|
|
330
|
+
const rule = this.rules.find((candidate) => candidate.id === ruleId);
|
|
331
|
+
if (!rule) throw new Error(`Automation rule "${ruleId}" not found`);
|
|
332
|
+
const execution = await this.executeRule(rule, event, event?.data ?? {});
|
|
333
|
+
return execution;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async replayExecution(executionId: string): Promise<AutomationExecution> {
|
|
337
|
+
const execution = this.executions.find((item) => item.id === executionId);
|
|
338
|
+
const persisted = this.store?.getAutomationExecution(executionId) ?? null;
|
|
339
|
+
const target = execution ?? (persisted ? {
|
|
340
|
+
id: persisted.id,
|
|
341
|
+
ruleId: persisted.rule_id,
|
|
342
|
+
ruleName: persisted.rule_name,
|
|
343
|
+
status: persisted.status as AutomationExecution["status"],
|
|
344
|
+
startedAt: persisted.started_at,
|
|
345
|
+
finishedAt: persisted.finished_at ?? undefined,
|
|
346
|
+
result: persisted.result ?? undefined,
|
|
347
|
+
error: persisted.error ?? undefined,
|
|
348
|
+
attempt: persisted.attempt,
|
|
349
|
+
maxAttempts: persisted.max_attempts,
|
|
350
|
+
triggerSource: persisted.trigger_source ?? undefined,
|
|
351
|
+
triggerType: persisted.trigger_type ?? undefined,
|
|
352
|
+
triggerPayload: persisted.trigger_payload ?? undefined,
|
|
353
|
+
actionType: this.resolveActionType(persisted.rule_id),
|
|
354
|
+
} : null);
|
|
355
|
+
if (!target) throw new Error(`Automation execution "${executionId}" not found`);
|
|
356
|
+
|
|
357
|
+
const rule = this.rules.find((candidate) => candidate.id === target.ruleId);
|
|
358
|
+
if (!rule) throw new Error(`Automation rule "${target.ruleId}" not found`);
|
|
359
|
+
|
|
360
|
+
let replayEvent: IngestedEvent | undefined;
|
|
361
|
+
let replayData: Record<string, unknown> = {};
|
|
362
|
+
if (target.triggerPayload) {
|
|
363
|
+
try {
|
|
364
|
+
const parsed = JSON.parse(target.triggerPayload) as { event?: IngestedEvent | null; data?: Record<string, unknown> };
|
|
365
|
+
replayEvent = parsed.event ?? undefined;
|
|
366
|
+
replayData = parsed.data ?? replayEvent?.data ?? {};
|
|
367
|
+
} catch {
|
|
368
|
+
replayData = {};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return this.executeRule(rule, replayEvent, replayData);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── Private ──────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
private matchesEvent(rule: AutomationRule, event: IngestedEvent): boolean {
|
|
378
|
+
if (rule.trigger.source && rule.trigger.source !== event.source) return false;
|
|
379
|
+
if (rule.trigger.eventType && rule.trigger.eventType !== event.type) return false;
|
|
380
|
+
if (rule.conditions && rule.conditions.length > 0) {
|
|
381
|
+
return rule.conditions.every((condition) => matchesCondition(condition, event.data));
|
|
382
|
+
}
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private isOnCooldown(rule: AutomationRule): boolean {
|
|
387
|
+
const last = this.lastFired.get(rule.id);
|
|
388
|
+
if (!last) return false;
|
|
389
|
+
const cooldown = rule.cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
|
390
|
+
return Date.now() - last < cooldown;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private resolveActionType(ruleId: string): AutomationExecution["actionType"] {
|
|
394
|
+
const rule = this.rules.find((candidate) => candidate.id === ruleId);
|
|
395
|
+
const type = rule ? normalizeAction(rule.action).type ?? "handoff" : "handoff";
|
|
396
|
+
return type;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private async executeRule(rule: AutomationRule, event?: IngestedEvent, data: Record<string, unknown> = {}): Promise<AutomationExecution> {
|
|
400
|
+
if (this.running >= MAX_CONCURRENT) {
|
|
401
|
+
throw new Error(`Automation busy: max concurrent (${MAX_CONCURRENT}) reached`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
this.lastFired.set(rule.id, Date.now());
|
|
405
|
+
this.running++;
|
|
406
|
+
|
|
407
|
+
const maxAttempts = clampInteger(rule.retry?.maxAttempts, 1, 1, 5);
|
|
408
|
+
const retryDelayMs = clampInteger(rule.retry?.delayMs, DEFAULT_RETRY_DELAY_MS, 0, 300_000);
|
|
409
|
+
const normalizedAction = normalizeAction(rule.action);
|
|
410
|
+
|
|
411
|
+
const execution: AutomationExecution = {
|
|
412
|
+
id: nanoid(),
|
|
413
|
+
ruleId: rule.id,
|
|
414
|
+
ruleName: rule.name,
|
|
415
|
+
status: "running",
|
|
416
|
+
startedAt: Date.now(),
|
|
417
|
+
attempt: 1,
|
|
418
|
+
maxAttempts,
|
|
419
|
+
triggerSource: event?.source,
|
|
420
|
+
triggerType: event?.type,
|
|
421
|
+
triggerPayload: serializeTriggerPayload(event, data),
|
|
422
|
+
actionType: normalizedAction.type ?? "handoff",
|
|
423
|
+
};
|
|
424
|
+
this.pushExecution(execution);
|
|
425
|
+
this.persistExecution(execution);
|
|
426
|
+
|
|
427
|
+
const templateContext = buildTemplateContext(event, data);
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
431
|
+
execution.attempt = attempt;
|
|
432
|
+
execution.error = undefined;
|
|
433
|
+
execution.result = undefined;
|
|
434
|
+
this.persistExecution(execution);
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
execution.result = await this.performAction(normalizedAction, templateContext, rule.name, execution);
|
|
438
|
+
execution.status = "success";
|
|
439
|
+
execution.finishedAt = Date.now();
|
|
440
|
+
this.persistExecution(execution);
|
|
441
|
+
if (rule.notify) this.sendCompletionNotification(rule, execution);
|
|
442
|
+
return execution;
|
|
443
|
+
} catch (err) {
|
|
444
|
+
execution.error = err instanceof Error ? err.message : String(err);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (attempt < maxAttempts && retryDelayMs > 0) {
|
|
448
|
+
this.persistExecution(execution);
|
|
449
|
+
await sleep(retryDelayMs);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
execution.status = "error";
|
|
454
|
+
execution.finishedAt = Date.now();
|
|
455
|
+
if (!execution.error) execution.error = "Automation failed after retries";
|
|
456
|
+
this.persistExecution(execution);
|
|
457
|
+
if (rule.notify) this.sendCompletionNotification(rule, execution);
|
|
458
|
+
return execution;
|
|
459
|
+
} finally {
|
|
460
|
+
this.running--;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private async performAction(
|
|
465
|
+
action: AutomationAction,
|
|
466
|
+
templateContext: Record<string, unknown>,
|
|
467
|
+
ruleName: string,
|
|
468
|
+
execution: AutomationExecution,
|
|
469
|
+
): Promise<string> {
|
|
470
|
+
if (action.type === "tool") {
|
|
471
|
+
if (!this.toolInvoker) {
|
|
472
|
+
throw new Error("Tool invoker not available");
|
|
473
|
+
}
|
|
474
|
+
const params = (interpolateUnknown(action.params ?? {}, templateContext) as Record<string, unknown>) ?? {};
|
|
475
|
+
const result = await this.toolInvoker.invoke(
|
|
476
|
+
interpolateTemplate(action.node, templateContext),
|
|
477
|
+
interpolateTemplate(action.tool, templateContext),
|
|
478
|
+
params,
|
|
479
|
+
action.timeoutMs,
|
|
480
|
+
);
|
|
481
|
+
return JSON.stringify(result);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (action.type === "notify") {
|
|
485
|
+
const title = interpolateTemplate(action.title, templateContext);
|
|
486
|
+
const detail = action.detail ? interpolateTemplate(action.detail, templateContext) : undefined;
|
|
487
|
+
this.broadcastNotify(title, detail, action.progress, action.success ?? true, execution.id);
|
|
488
|
+
return detail ? `${title}: ${detail}` : title;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const task = interpolateTemplate(action.task, templateContext);
|
|
492
|
+
debug("automation", `executing rule "${ruleName}" (attempt ${execution.attempt}/${execution.maxAttempts}) → agent "${action.agent}": ${task.slice(0, 100)}`);
|
|
493
|
+
const result = await this.handoffManager.handoff(action.agent, task);
|
|
494
|
+
if (!result.success) {
|
|
495
|
+
throw new Error(result.error ?? "Automation handoff failed");
|
|
496
|
+
}
|
|
497
|
+
return result.result ?? "";
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private sendCompletionNotification(rule: AutomationRule, execution: AutomationExecution): void {
|
|
501
|
+
const title = execution.status === "success"
|
|
502
|
+
? `✅ ${rule.name} 完成`
|
|
503
|
+
: `❌ ${rule.name} 失败`;
|
|
504
|
+
const baseDetail = execution.status === "success"
|
|
505
|
+
? (execution.result?.slice(0, 200) ?? "")
|
|
506
|
+
: (execution.error?.slice(0, 200) ?? "");
|
|
507
|
+
const detail = execution.maxAttempts > 1
|
|
508
|
+
? `[尝试 ${execution.attempt}/${execution.maxAttempts}] ${baseDetail}`.trim()
|
|
509
|
+
: baseDetail;
|
|
510
|
+
this.broadcastNotify(title, detail, execution.status === "success" ? 1 : undefined, execution.status === "success", execution.id);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private broadcastNotify(title: string, detail?: string, progress?: number, success = true, executionId?: string): void {
|
|
514
|
+
try {
|
|
515
|
+
this.peerManager.router.broadcast({
|
|
516
|
+
type: "task_activity",
|
|
517
|
+
id: nanoid(),
|
|
518
|
+
from: this.nodeId,
|
|
519
|
+
timestamp: Date.now(),
|
|
520
|
+
payload: {
|
|
521
|
+
action: "update",
|
|
522
|
+
taskId: `automation:${executionId ?? nanoid()}`,
|
|
523
|
+
title,
|
|
524
|
+
detail,
|
|
525
|
+
progress,
|
|
526
|
+
status: success ? "completed" : "error",
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
} catch {
|
|
530
|
+
// Non-critical
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private pushExecution(execution: AutomationExecution): void {
|
|
535
|
+
this.executions.push(execution);
|
|
536
|
+
if (this.executions.length > MAX_EXECUTIONS) {
|
|
537
|
+
this.executions.splice(0, this.executions.length - MAX_EXECUTIONS);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private persistExecution(execution: AutomationExecution): void {
|
|
542
|
+
if (!this.store) return;
|
|
543
|
+
try {
|
|
544
|
+
this.store.upsertAutomationExecution({
|
|
545
|
+
id: execution.id,
|
|
546
|
+
ruleId: execution.ruleId,
|
|
547
|
+
ruleName: execution.ruleName,
|
|
548
|
+
status: execution.status,
|
|
549
|
+
startedAt: execution.startedAt,
|
|
550
|
+
finishedAt: execution.finishedAt,
|
|
551
|
+
result: execution.result,
|
|
552
|
+
error: execution.error,
|
|
553
|
+
attempt: execution.attempt,
|
|
554
|
+
maxAttempts: execution.maxAttempts,
|
|
555
|
+
triggerSource: execution.triggerSource,
|
|
556
|
+
triggerType: execution.triggerType,
|
|
557
|
+
triggerPayload: execution.triggerPayload,
|
|
558
|
+
});
|
|
559
|
+
} catch {
|
|
560
|
+
// Non-fatal
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private async loadRules(): Promise<void> {
|
|
565
|
+
try {
|
|
566
|
+
const content = await readFile(this.filePath, "utf-8");
|
|
567
|
+
const parsed = JSON.parse(content) as AutomationsFile;
|
|
568
|
+
if (Array.isArray(parsed.rules)) {
|
|
569
|
+
this.rules = parsed.rules.map((rule) => ({ ...rule, action: normalizeAction(rule.action) }));
|
|
570
|
+
}
|
|
571
|
+
} catch {
|
|
572
|
+
this.rules = [];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private startScheduleTimers(): void {
|
|
577
|
+
for (const rule of this.rules) {
|
|
578
|
+
if (!rule.enabled) continue;
|
|
579
|
+
if (rule.trigger.type !== "schedule") continue;
|
|
580
|
+
if (!rule.trigger.intervalMs || rule.trigger.intervalMs < 10_000) continue;
|
|
581
|
+
|
|
582
|
+
const timer = setInterval(() => {
|
|
583
|
+
if (!this.isOnCooldown(rule)) {
|
|
584
|
+
void this.executeRule(rule);
|
|
585
|
+
}
|
|
586
|
+
}, rule.trigger.intervalMs);
|
|
587
|
+
|
|
588
|
+
this.scheduleTimers.set(rule.id, timer);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private stopScheduleTimers(): void {
|
|
593
|
+
for (const timer of this.scheduleTimers.values()) {
|
|
594
|
+
clearInterval(timer);
|
|
595
|
+
}
|
|
596
|
+
this.scheduleTimers.clear();
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private startFileWatch(): void {
|
|
600
|
+
try {
|
|
601
|
+
this.watcher = watch(this.filePath, () => {
|
|
602
|
+
if (this.watchDebounce) clearTimeout(this.watchDebounce);
|
|
603
|
+
this.watchDebounce = setTimeout(() => {
|
|
604
|
+
this.watchDebounce = null;
|
|
605
|
+
void this.reloadFromFile();
|
|
606
|
+
}, FILE_WATCH_DEBOUNCE_MS);
|
|
607
|
+
});
|
|
608
|
+
} catch {
|
|
609
|
+
// File may not exist yet
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private async reloadFromFile(): Promise<void> {
|
|
614
|
+
const oldRuleIds = new Set(this.rules.map((r) => r.id));
|
|
615
|
+
await this.loadRules();
|
|
616
|
+
const newRuleIds = new Set(this.rules.map((r) => r.id));
|
|
617
|
+
|
|
618
|
+
if (oldRuleIds.size !== newRuleIds.size || [...oldRuleIds].some((id) => !newRuleIds.has(id))) {
|
|
619
|
+
this.stopScheduleTimers();
|
|
620
|
+
this.startScheduleTimers();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
debug("automation", `reloaded ${this.rules.length} rule(s) from file`);
|
|
624
|
+
}
|
|
625
|
+
}
|