groove-dev 0.16.4 → 0.17.1
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 +18 -16
- package/node_modules/@groove-dev/daemon/integrations-registry.json +417 -0
- package/node_modules/@groove-dev/daemon/src/api.js +204 -0
- package/node_modules/@groove-dev/daemon/src/index.js +9 -0
- package/node_modules/@groove-dev/daemon/src/integrations.js +475 -0
- package/node_modules/@groove-dev/daemon/src/introducer.js +23 -0
- package/node_modules/@groove-dev/daemon/src/process.js +59 -0
- package/node_modules/@groove-dev/daemon/src/registry.js +2 -1
- package/node_modules/@groove-dev/daemon/src/scheduler.js +336 -0
- package/node_modules/@groove-dev/daemon/src/validate.js +10 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-CEf7nLM2.js +156 -0
- package/node_modules/@groove-dev/gui/dist/index.html +1 -1
- package/node_modules/@groove-dev/gui/src/App.jsx +6 -0
- package/node_modules/@groove-dev/gui/src/components/SpawnPanel.jsx +98 -7
- package/node_modules/@groove-dev/gui/src/views/IntegrationsStore.jsx +1171 -0
- package/node_modules/@groove-dev/gui/src/views/ScheduleManager.jsx +614 -0
- package/package.json +2 -2
- package/packages/daemon/integrations-registry.json +417 -0
- package/packages/daemon/src/api.js +204 -0
- package/packages/daemon/src/index.js +9 -0
- package/packages/daemon/src/integrations.js +475 -0
- package/packages/daemon/src/introducer.js +23 -0
- package/packages/daemon/src/process.js +59 -0
- package/packages/daemon/src/registry.js +2 -1
- package/packages/daemon/src/scheduler.js +336 -0
- package/packages/daemon/src/validate.js +10 -0
- package/packages/gui/dist/assets/index-CEf7nLM2.js +156 -0
- package/packages/gui/dist/index.html +1 -1
- package/packages/gui/src/App.jsx +6 -0
- package/packages/gui/src/components/SpawnPanel.jsx +98 -7
- package/packages/gui/src/views/IntegrationsStore.jsx +1171 -0
- package/packages/gui/src/views/ScheduleManager.jsx +614 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-B_VHpncx.js +0 -153
- package/packages/gui/dist/assets/index-B_VHpncx.js +0 -153
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// GROOVE — Agent Scheduler (Cron-based agent spawning)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
import { randomUUID } from 'crypto';
|
|
7
|
+
|
|
8
|
+
// Simple cron field parser — supports: *, N, */N
|
|
9
|
+
// Fields: minute(0-59) hour(0-23) dayOfMonth(1-31) month(1-12) dayOfWeek(0-6)
|
|
10
|
+
function parseCronField(field, min, max) {
|
|
11
|
+
if (field === '*') return null; // any
|
|
12
|
+
if (field.startsWith('*/')) {
|
|
13
|
+
const step = parseInt(field.slice(2), 10);
|
|
14
|
+
if (isNaN(step) || step <= 0) return null;
|
|
15
|
+
return { type: 'step', step };
|
|
16
|
+
}
|
|
17
|
+
const val = parseInt(field, 10);
|
|
18
|
+
if (!isNaN(val) && val >= min && val <= max) {
|
|
19
|
+
return { type: 'exact', value: val };
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fieldMatches(parsed, value) {
|
|
25
|
+
if (parsed === null) return true; // wildcard
|
|
26
|
+
if (parsed.type === 'exact') return value === parsed.value;
|
|
27
|
+
if (parsed.type === 'step') return value % parsed.step === 0;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cronMatches(cronExpr, date) {
|
|
32
|
+
const parts = cronExpr.trim().split(/\s+/);
|
|
33
|
+
if (parts.length !== 5) return false;
|
|
34
|
+
|
|
35
|
+
const fields = [
|
|
36
|
+
parseCronField(parts[0], 0, 59), // minute
|
|
37
|
+
parseCronField(parts[1], 0, 23), // hour
|
|
38
|
+
parseCronField(parts[2], 1, 31), // day of month
|
|
39
|
+
parseCronField(parts[3], 1, 12), // month
|
|
40
|
+
parseCronField(parts[4], 0, 6), // day of week
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
fieldMatches(fields[0], date.getMinutes()) &&
|
|
45
|
+
fieldMatches(fields[1], date.getHours()) &&
|
|
46
|
+
fieldMatches(fields[2], date.getDate()) &&
|
|
47
|
+
fieldMatches(fields[3], date.getMonth() + 1) &&
|
|
48
|
+
fieldMatches(fields[4], date.getDay())
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Human-readable cron description
|
|
53
|
+
function describeCron(cron) {
|
|
54
|
+
const presets = {
|
|
55
|
+
'* * * * *': 'Every minute',
|
|
56
|
+
'*/5 * * * *': 'Every 5 minutes',
|
|
57
|
+
'*/15 * * * *': 'Every 15 minutes',
|
|
58
|
+
'*/30 * * * *': 'Every 30 minutes',
|
|
59
|
+
'0 * * * *': 'Every hour',
|
|
60
|
+
'0 */2 * * *': 'Every 2 hours',
|
|
61
|
+
'0 */6 * * *': 'Every 6 hours',
|
|
62
|
+
'0 0 * * *': 'Daily at midnight',
|
|
63
|
+
'0 9 * * *': 'Daily at 9:00 AM',
|
|
64
|
+
'0 9 * * 1-5': 'Weekdays at 9:00 AM',
|
|
65
|
+
'0 0 * * 0': 'Weekly (Sunday midnight)',
|
|
66
|
+
'0 0 * * 1': 'Weekly (Monday midnight)',
|
|
67
|
+
'0 0 1 * *': 'Monthly (1st at midnight)',
|
|
68
|
+
};
|
|
69
|
+
return presets[cron] || cron;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const CHECK_INTERVAL = 60_000; // 1 minute
|
|
73
|
+
const MAX_HISTORY = 50;
|
|
74
|
+
|
|
75
|
+
export class Scheduler {
|
|
76
|
+
constructor(daemon) {
|
|
77
|
+
this.daemon = daemon;
|
|
78
|
+
this.schedulesDir = resolve(daemon.grooveDir, 'schedules');
|
|
79
|
+
mkdirSync(this.schedulesDir, { recursive: true });
|
|
80
|
+
this.schedules = new Map();
|
|
81
|
+
this.runningAgents = new Map(); // scheduleId -> agentId
|
|
82
|
+
this.history = new Map(); // scheduleId -> [{ timestamp, agentId, status }]
|
|
83
|
+
this.interval = null;
|
|
84
|
+
this._load();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create a new schedule.
|
|
89
|
+
*/
|
|
90
|
+
create(config) {
|
|
91
|
+
if (!config.name) throw new Error('Schedule name is required');
|
|
92
|
+
if (!config.cron) throw new Error('Cron expression is required');
|
|
93
|
+
if (!config.agentConfig) throw new Error('Agent config is required');
|
|
94
|
+
if (!config.agentConfig.role) throw new Error('Agent role is required');
|
|
95
|
+
|
|
96
|
+
// Validate cron (basic check)
|
|
97
|
+
const parts = config.cron.trim().split(/\s+/);
|
|
98
|
+
if (parts.length !== 5) throw new Error('Cron must have 5 fields: minute hour day month weekday');
|
|
99
|
+
|
|
100
|
+
const schedule = {
|
|
101
|
+
id: randomUUID().slice(0, 8),
|
|
102
|
+
name: config.name,
|
|
103
|
+
cron: config.cron.trim(),
|
|
104
|
+
cronDescription: describeCron(config.cron.trim()),
|
|
105
|
+
agentConfig: config.agentConfig,
|
|
106
|
+
enabled: config.enabled !== false,
|
|
107
|
+
maxConcurrent: config.maxConcurrent || 1,
|
|
108
|
+
createdAt: new Date().toISOString(),
|
|
109
|
+
updatedAt: new Date().toISOString(),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
this.schedules.set(schedule.id, schedule);
|
|
113
|
+
this.history.set(schedule.id, []);
|
|
114
|
+
this._save(schedule.id);
|
|
115
|
+
|
|
116
|
+
this.daemon.audit.log('schedule.create', { id: schedule.id, name: schedule.name, cron: schedule.cron });
|
|
117
|
+
|
|
118
|
+
return schedule;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Update an existing schedule.
|
|
123
|
+
*/
|
|
124
|
+
update(id, updates) {
|
|
125
|
+
const schedule = this.schedules.get(id);
|
|
126
|
+
if (!schedule) throw new Error(`Schedule not found: ${id}`);
|
|
127
|
+
|
|
128
|
+
const SAFE = ['name', 'cron', 'agentConfig', 'enabled', 'maxConcurrent'];
|
|
129
|
+
for (const key of Object.keys(updates)) {
|
|
130
|
+
if (SAFE.includes(key)) {
|
|
131
|
+
schedule[key] = updates[key];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (updates.cron) {
|
|
135
|
+
schedule.cronDescription = describeCron(updates.cron.trim());
|
|
136
|
+
}
|
|
137
|
+
schedule.updatedAt = new Date().toISOString();
|
|
138
|
+
this._save(id);
|
|
139
|
+
|
|
140
|
+
return schedule;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Delete a schedule.
|
|
145
|
+
*/
|
|
146
|
+
delete(id) {
|
|
147
|
+
if (!this.schedules.has(id)) throw new Error(`Schedule not found: ${id}`);
|
|
148
|
+
this.schedules.delete(id);
|
|
149
|
+
this.history.delete(id);
|
|
150
|
+
this.runningAgents.delete(id);
|
|
151
|
+
|
|
152
|
+
const filePath = resolve(this.schedulesDir, `${id}.json`);
|
|
153
|
+
if (existsSync(filePath)) unlinkSync(filePath);
|
|
154
|
+
|
|
155
|
+
this.daemon.audit.log('schedule.delete', { id });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Enable a schedule.
|
|
160
|
+
*/
|
|
161
|
+
enable(id) {
|
|
162
|
+
const schedule = this.schedules.get(id);
|
|
163
|
+
if (!schedule) throw new Error(`Schedule not found: ${id}`);
|
|
164
|
+
schedule.enabled = true;
|
|
165
|
+
schedule.updatedAt = new Date().toISOString();
|
|
166
|
+
this._save(id);
|
|
167
|
+
return schedule;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Disable a schedule.
|
|
172
|
+
*/
|
|
173
|
+
disable(id) {
|
|
174
|
+
const schedule = this.schedules.get(id);
|
|
175
|
+
if (!schedule) throw new Error(`Schedule not found: ${id}`);
|
|
176
|
+
schedule.enabled = false;
|
|
177
|
+
schedule.updatedAt = new Date().toISOString();
|
|
178
|
+
this._save(id);
|
|
179
|
+
return schedule;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* List all schedules with their current state.
|
|
184
|
+
*/
|
|
185
|
+
list() {
|
|
186
|
+
return Array.from(this.schedules.values()).map((s) => ({
|
|
187
|
+
...s,
|
|
188
|
+
lastRun: this._lastRun(s.id),
|
|
189
|
+
isRunning: this.runningAgents.has(s.id),
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get a specific schedule with history.
|
|
195
|
+
*/
|
|
196
|
+
get(id) {
|
|
197
|
+
const schedule = this.schedules.get(id);
|
|
198
|
+
if (!schedule) return null;
|
|
199
|
+
return {
|
|
200
|
+
...schedule,
|
|
201
|
+
history: this.history.get(id) || [],
|
|
202
|
+
lastRun: this._lastRun(id),
|
|
203
|
+
isRunning: this.runningAgents.has(id),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Manually trigger a schedule (run now).
|
|
209
|
+
*/
|
|
210
|
+
async run(id) {
|
|
211
|
+
const schedule = this.schedules.get(id);
|
|
212
|
+
if (!schedule) throw new Error(`Schedule not found: ${id}`);
|
|
213
|
+
return this._execute(schedule);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Start the scheduler (check every minute).
|
|
218
|
+
*/
|
|
219
|
+
start() {
|
|
220
|
+
if (this.interval) return;
|
|
221
|
+
this.interval = setInterval(() => this._check(), CHECK_INTERVAL);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Stop the scheduler.
|
|
226
|
+
*/
|
|
227
|
+
stop() {
|
|
228
|
+
if (this.interval) {
|
|
229
|
+
clearInterval(this.interval);
|
|
230
|
+
this.interval = null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- Internal ---
|
|
235
|
+
|
|
236
|
+
_check() {
|
|
237
|
+
const now = new Date();
|
|
238
|
+
for (const schedule of this.schedules.values()) {
|
|
239
|
+
if (!schedule.enabled) continue;
|
|
240
|
+
if (cronMatches(schedule.cron, now)) {
|
|
241
|
+
// Check concurrency
|
|
242
|
+
if (this.runningAgents.has(schedule.id)) {
|
|
243
|
+
const agentId = this.runningAgents.get(schedule.id);
|
|
244
|
+
const agent = this.daemon.registry.get(agentId);
|
|
245
|
+
if (agent && (agent.status === 'running' || agent.status === 'starting')) {
|
|
246
|
+
// Still running — skip
|
|
247
|
+
this._recordHistory(schedule.id, null, 'skipped');
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
// Agent finished — clear
|
|
251
|
+
this.runningAgents.delete(schedule.id);
|
|
252
|
+
}
|
|
253
|
+
this._execute(schedule).catch(() => {});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async _execute(schedule) {
|
|
259
|
+
try {
|
|
260
|
+
const agent = await this.daemon.processes.spawn({
|
|
261
|
+
...schedule.agentConfig,
|
|
262
|
+
name: `sched-${schedule.name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 20)}`,
|
|
263
|
+
});
|
|
264
|
+
this.runningAgents.set(schedule.id, agent.id);
|
|
265
|
+
this._recordHistory(schedule.id, agent.id, 'spawned');
|
|
266
|
+
|
|
267
|
+
this.daemon.broadcast({
|
|
268
|
+
type: 'schedule:execute',
|
|
269
|
+
scheduleId: schedule.id,
|
|
270
|
+
agentId: agent.id,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
this.daemon.audit.log('schedule.execute', {
|
|
274
|
+
id: schedule.id,
|
|
275
|
+
name: schedule.name,
|
|
276
|
+
agentId: agent.id,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return agent;
|
|
280
|
+
} catch (err) {
|
|
281
|
+
this._recordHistory(schedule.id, null, 'error', err.message);
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
_recordHistory(scheduleId, agentId, status, error) {
|
|
287
|
+
const history = this.history.get(scheduleId) || [];
|
|
288
|
+
history.unshift({
|
|
289
|
+
timestamp: new Date().toISOString(),
|
|
290
|
+
agentId,
|
|
291
|
+
status,
|
|
292
|
+
error,
|
|
293
|
+
});
|
|
294
|
+
// Keep only last N entries
|
|
295
|
+
if (history.length > MAX_HISTORY) history.length = MAX_HISTORY;
|
|
296
|
+
this.history.set(scheduleId, history);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
_lastRun(scheduleId) {
|
|
300
|
+
const history = this.history.get(scheduleId) || [];
|
|
301
|
+
return history[0] || null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
_save(id) {
|
|
305
|
+
const schedule = this.schedules.get(id);
|
|
306
|
+
if (!schedule) return;
|
|
307
|
+
const filePath = resolve(this.schedulesDir, `${id}.json`);
|
|
308
|
+
writeFileSync(filePath, JSON.stringify({
|
|
309
|
+
...schedule,
|
|
310
|
+
history: this.history.get(id) || [],
|
|
311
|
+
}, null, 2));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
_load() {
|
|
315
|
+
if (!existsSync(this.schedulesDir)) return;
|
|
316
|
+
for (const file of readdirSync(this.schedulesDir)) {
|
|
317
|
+
if (!file.endsWith('.json')) continue;
|
|
318
|
+
try {
|
|
319
|
+
const data = JSON.parse(readFileSync(resolve(this.schedulesDir, file), 'utf8'));
|
|
320
|
+
const id = data.id || file.replace('.json', '');
|
|
321
|
+
this.schedules.set(id, {
|
|
322
|
+
id,
|
|
323
|
+
name: data.name,
|
|
324
|
+
cron: data.cron,
|
|
325
|
+
cronDescription: describeCron(data.cron),
|
|
326
|
+
agentConfig: data.agentConfig,
|
|
327
|
+
enabled: data.enabled !== false,
|
|
328
|
+
maxConcurrent: data.maxConcurrent || 1,
|
|
329
|
+
createdAt: data.createdAt,
|
|
330
|
+
updatedAt: data.updatedAt,
|
|
331
|
+
});
|
|
332
|
+
this.history.set(id, data.history || []);
|
|
333
|
+
} catch { /* skip corrupt files */ }
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -66,6 +66,15 @@ export function validateAgentConfig(config) {
|
|
|
66
66
|
skills = config.skills.filter((s) => typeof s === 'string' && s.length > 0 && s.length <= 100);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Validate integrations (array of integration IDs)
|
|
70
|
+
let integrations = [];
|
|
71
|
+
if (config.integrations !== undefined && config.integrations !== null) {
|
|
72
|
+
if (!Array.isArray(config.integrations)) {
|
|
73
|
+
throw new Error('Integrations must be an array');
|
|
74
|
+
}
|
|
75
|
+
integrations = config.integrations.filter((s) => typeof s === 'string' && s.length > 0 && s.length <= 100);
|
|
76
|
+
}
|
|
77
|
+
|
|
69
78
|
// Return sanitized config (only known fields)
|
|
70
79
|
return {
|
|
71
80
|
role: config.role,
|
|
@@ -77,6 +86,7 @@ export function validateAgentConfig(config) {
|
|
|
77
86
|
workingDir: typeof config.workingDir === 'string' ? config.workingDir : undefined,
|
|
78
87
|
permission,
|
|
79
88
|
skills,
|
|
89
|
+
integrations,
|
|
80
90
|
};
|
|
81
91
|
}
|
|
82
92
|
|