lazy-gravity 0.10.0 → 0.12.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 +7 -0
- package/dist/bin/commands/doctor.js +45 -7
- package/dist/bot/index.js +526 -24
- package/dist/commands/chatCommandHandler.js +11 -41
- package/dist/commands/registerSlashCommands.js +62 -0
- package/dist/database/scheduleRepository.js +50 -6
- package/dist/events/interactionCreateHandler.js +163 -28
- package/dist/events/messageCreateHandler.js +24 -28
- package/dist/handlers/fileChangeButtonAction.js +12 -24
- package/dist/handlers/genericActionButtonAction.js +16 -18
- package/dist/handlers/planningButtonAction.js +105 -17
- package/dist/handlers/planningModalSubmitAction.js +38 -0
- package/dist/handlers/questionSelectAction.js +8 -7
- package/dist/handlers/questionSkipAction.js +7 -6
- package/dist/platform/discord/wrappers.js +76 -0
- package/dist/services/approvalDetector.js +43 -14
- package/dist/services/artifactService.js +61 -21
- package/dist/services/assistantDomExtractor.js +23 -3
- package/dist/services/cdpBridgeManager.js +35 -2
- package/dist/services/cdpConnectionPool.js +7 -2
- package/dist/services/cdpService.js +113 -8
- package/dist/services/chatSessionService.js +15 -8
- package/dist/services/heartbeatService.js +261 -0
- package/dist/services/notificationSender.js +12 -2
- package/dist/services/planningDetector.js +1 -1
- package/dist/services/promptDispatcher.js +21 -1
- package/dist/services/questionDetector.js +128 -76
- package/dist/services/responseMonitor.js +17 -1
- package/dist/services/scheduleService.js +101 -4
- package/dist/utils/configLoader.js +8 -0
- package/dist/utils/fileOpenCache.js +2 -6
- package/dist/utils/questionActionUtils.js +22 -0
- package/package.json +2 -1
|
@@ -61,24 +61,37 @@ class QuestionDetector extends events_1.EventEmitter {
|
|
|
61
61
|
const callParams = {
|
|
62
62
|
expression: `
|
|
63
63
|
(() => {
|
|
64
|
+
const getInteractiveItems = (elContainer) => {
|
|
65
|
+
return Array.from(elContainer.querySelectorAll('li, label, a, [role="radio"], [role="option"], [class*="cursor-pointer"]'))
|
|
66
|
+
.filter(el => {
|
|
67
|
+
if (el.tagName === 'BUTTON' || el.closest('button')) return false;
|
|
68
|
+
const role = el.getAttribute('role');
|
|
69
|
+
if (['radio', 'option', 'checkbox', 'button', 'menuitem'].includes(role)) return true;
|
|
70
|
+
if (el.tagName === 'A' || el.tagName === 'LABEL') return true;
|
|
71
|
+
const style = window.getComputedStyle(el);
|
|
72
|
+
return style.cursor === 'pointer';
|
|
73
|
+
});
|
|
74
|
+
};
|
|
64
75
|
const containers = Array.from(document.querySelectorAll('div, form, dialog')).reverse();
|
|
65
76
|
let targetList = null;
|
|
66
77
|
let submitBtn = null;
|
|
67
78
|
|
|
68
79
|
for (const container of containers) {
|
|
80
|
+
const hasTextInput = container.querySelector('textarea, input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]), [contenteditable], [role="textbox"], .monaco-editor, .editor-container, .inputarea, vscode-text-field, vscode-text-area');
|
|
81
|
+
const hasExplicitQuestionItems = container.querySelector('[role="radio"], input[type="radio"], input[type="checkbox"], [role="option"], [role="checkbox"]');
|
|
82
|
+
if (hasTextInput && !hasExplicitQuestionItems) continue;
|
|
69
83
|
const buttons = Array.from(container.querySelectorAll('button'));
|
|
70
84
|
let possibleSubmitBtn = null;
|
|
71
85
|
for (const btn of buttons) {
|
|
72
86
|
const text = btn.textContent?.toLowerCase() || '';
|
|
73
|
-
if (text.includes('submit')) {
|
|
87
|
+
if (text.includes('submit') || text.includes('continue')) {
|
|
74
88
|
possibleSubmitBtn = btn;
|
|
75
89
|
break;
|
|
76
90
|
}
|
|
77
91
|
}
|
|
78
92
|
|
|
79
93
|
if (possibleSubmitBtn) {
|
|
80
|
-
const items =
|
|
81
|
-
.filter(el => el.tagName !== 'BUTTON' && !el.closest('button'));
|
|
94
|
+
const items = getInteractiveItems(container);
|
|
82
95
|
|
|
83
96
|
if (items.length > 1) {
|
|
84
97
|
targetList = container;
|
|
@@ -90,30 +103,38 @@ class QuestionDetector extends events_1.EventEmitter {
|
|
|
90
103
|
|
|
91
104
|
if (!targetList || !submitBtn) return { found: false };
|
|
92
105
|
|
|
93
|
-
const items =
|
|
94
|
-
.filter(el => el.tagName !== 'BUTTON' && !el.closest('button'));
|
|
106
|
+
const items = getInteractiveItems(targetList);
|
|
95
107
|
if (items.length <= ${index}) return { found: false };
|
|
96
108
|
|
|
97
109
|
const targetOption = items[${index}];
|
|
98
110
|
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
const clickElement = (el) => {
|
|
112
|
+
const rect = el.getBoundingClientRect();
|
|
113
|
+
const clickX = rect.left + rect.width / 2;
|
|
114
|
+
const clickY = rect.top + rect.height / 2;
|
|
115
|
+
const events = ['pointerdown', 'mousedown', 'mouseup', 'click'];
|
|
116
|
+
for (const type of events) {
|
|
117
|
+
el.dispatchEvent(new MouseEvent(type, {
|
|
118
|
+
bubbles: true,
|
|
119
|
+
cancelable: true,
|
|
120
|
+
view: window,
|
|
121
|
+
clientX: clickX,
|
|
122
|
+
clientY: clickY,
|
|
123
|
+
}));
|
|
111
124
|
}
|
|
112
125
|
};
|
|
126
|
+
|
|
127
|
+
return new Promise(resolve => {
|
|
128
|
+
clickElement(targetOption);
|
|
129
|
+
setTimeout(() => {
|
|
130
|
+
clickElement(submitBtn);
|
|
131
|
+
resolve({ found: true });
|
|
132
|
+
}, 50);
|
|
133
|
+
});
|
|
113
134
|
})()
|
|
114
135
|
`,
|
|
115
136
|
returnByValue: true,
|
|
116
|
-
awaitPromise:
|
|
137
|
+
awaitPromise: true,
|
|
117
138
|
};
|
|
118
139
|
if (contextId !== null) {
|
|
119
140
|
callParams.contextId = contextId;
|
|
@@ -124,37 +145,6 @@ class QuestionDetector extends events_1.EventEmitter {
|
|
|
124
145
|
this.logger.warn(`[QuestionDetector:${this.projectName}] Could not find question modal elements during submission.`);
|
|
125
146
|
return false;
|
|
126
147
|
}
|
|
127
|
-
// Click the option
|
|
128
|
-
await this.cdp.call('Input.dispatchMouseEvent', {
|
|
129
|
-
type: 'mousePressed',
|
|
130
|
-
x: result.option.x,
|
|
131
|
-
y: result.option.y,
|
|
132
|
-
button: 'left',
|
|
133
|
-
clickCount: 1,
|
|
134
|
-
});
|
|
135
|
-
await this.cdp.call('Input.dispatchMouseEvent', {
|
|
136
|
-
type: 'mouseReleased',
|
|
137
|
-
x: result.option.x,
|
|
138
|
-
y: result.option.y,
|
|
139
|
-
button: 'left',
|
|
140
|
-
clickCount: 1,
|
|
141
|
-
});
|
|
142
|
-
await new Promise(resolve => setTimeout(resolve, 50));
|
|
143
|
-
// Click the submit button
|
|
144
|
-
await this.cdp.call('Input.dispatchMouseEvent', {
|
|
145
|
-
type: 'mousePressed',
|
|
146
|
-
x: result.button.x,
|
|
147
|
-
y: result.button.y,
|
|
148
|
-
button: 'left',
|
|
149
|
-
clickCount: 1,
|
|
150
|
-
});
|
|
151
|
-
await this.cdp.call('Input.dispatchMouseEvent', {
|
|
152
|
-
type: 'mouseReleased',
|
|
153
|
-
x: result.button.x,
|
|
154
|
-
y: result.button.y,
|
|
155
|
-
button: 'left',
|
|
156
|
-
clickCount: 1,
|
|
157
|
-
});
|
|
158
148
|
this.lastQuestionDetected = false;
|
|
159
149
|
return true;
|
|
160
150
|
}
|
|
@@ -170,12 +160,26 @@ class QuestionDetector extends events_1.EventEmitter {
|
|
|
170
160
|
const callParams = {
|
|
171
161
|
expression: `
|
|
172
162
|
(() => {
|
|
163
|
+
const getInteractiveItems = (elContainer) => {
|
|
164
|
+
return Array.from(elContainer.querySelectorAll('li, label, a, [role="radio"], [role="option"], [class*="cursor-pointer"]'))
|
|
165
|
+
.filter(el => {
|
|
166
|
+
if (el.tagName === 'BUTTON' || el.closest('button')) return false;
|
|
167
|
+
const role = el.getAttribute('role');
|
|
168
|
+
if (['radio', 'option', 'checkbox', 'button', 'menuitem'].includes(role)) return true;
|
|
169
|
+
if (el.tagName === 'A' || el.tagName === 'LABEL') return true;
|
|
170
|
+
const style = window.getComputedStyle(el);
|
|
171
|
+
return style.cursor === 'pointer';
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
|
|
173
175
|
const containers = Array.from(document.querySelectorAll('div, form, dialog')).reverse();
|
|
174
176
|
let skipBtn = null;
|
|
175
177
|
|
|
176
178
|
for (const container of containers) {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
+
const hasTextInput = container.querySelector('textarea, input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]), [contenteditable="true"], [contenteditable=""], [role="textbox"]');
|
|
180
|
+
const hasExplicitQuestionItems = container.querySelector('[role="radio"], input[type="radio"], input[type="checkbox"], [role="option"], [role="checkbox"]');
|
|
181
|
+
if (hasTextInput && !hasExplicitQuestionItems) continue;
|
|
182
|
+
const items = getInteractiveItems(container);
|
|
179
183
|
const hasList = items.length > 1;
|
|
180
184
|
|
|
181
185
|
const buttons = Array.from(container.querySelectorAll('button'));
|
|
@@ -191,14 +195,24 @@ class QuestionDetector extends events_1.EventEmitter {
|
|
|
191
195
|
|
|
192
196
|
if (!skipBtn) return { found: false };
|
|
193
197
|
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
const clickElement = (el) => {
|
|
199
|
+
const rect = el.getBoundingClientRect();
|
|
200
|
+
const clickX = rect.left + rect.width / 2;
|
|
201
|
+
const clickY = rect.top + rect.height / 2;
|
|
202
|
+
const events = ['pointerdown', 'mousedown', 'mouseup', 'click'];
|
|
203
|
+
for (const type of events) {
|
|
204
|
+
el.dispatchEvent(new MouseEvent(type, {
|
|
205
|
+
bubbles: true,
|
|
206
|
+
cancelable: true,
|
|
207
|
+
view: window,
|
|
208
|
+
clientX: clickX,
|
|
209
|
+
clientY: clickY,
|
|
210
|
+
}));
|
|
200
211
|
}
|
|
201
212
|
};
|
|
213
|
+
|
|
214
|
+
clickElement(skipBtn);
|
|
215
|
+
return { found: true };
|
|
202
216
|
})()
|
|
203
217
|
`,
|
|
204
218
|
returnByValue: true,
|
|
@@ -212,20 +226,6 @@ class QuestionDetector extends events_1.EventEmitter {
|
|
|
212
226
|
this.logger.warn(`[QuestionDetector:${this.projectName}] Could not find skip button.`);
|
|
213
227
|
return false;
|
|
214
228
|
}
|
|
215
|
-
await this.cdp.call('Input.dispatchMouseEvent', {
|
|
216
|
-
type: 'mousePressed',
|
|
217
|
-
x: result.button.x,
|
|
218
|
-
y: result.button.y,
|
|
219
|
-
button: 'left',
|
|
220
|
-
clickCount: 1,
|
|
221
|
-
});
|
|
222
|
-
await this.cdp.call('Input.dispatchMouseEvent', {
|
|
223
|
-
type: 'mouseReleased',
|
|
224
|
-
x: result.button.x,
|
|
225
|
-
y: result.button.y,
|
|
226
|
-
button: 'left',
|
|
227
|
-
clickCount: 1,
|
|
228
|
-
});
|
|
229
229
|
this.lastQuestionDetected = false;
|
|
230
230
|
return true;
|
|
231
231
|
}
|
|
@@ -241,24 +241,73 @@ class QuestionDetector extends events_1.EventEmitter {
|
|
|
241
241
|
const callParams = {
|
|
242
242
|
expression: `
|
|
243
243
|
(() => {
|
|
244
|
+
const STOP_PATTERNS = [
|
|
245
|
+
/^stop$/,
|
|
246
|
+
/^stop generating$/,
|
|
247
|
+
/^stop response$/,
|
|
248
|
+
/^停止$/,
|
|
249
|
+
/^生成を停止$/,
|
|
250
|
+
/^応答を停止$/,
|
|
251
|
+
];
|
|
252
|
+
const normalize = (value) => (value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
|
|
253
|
+
const isStopLabel = (value) => {
|
|
254
|
+
const normalized = normalize(value);
|
|
255
|
+
if (!normalized) return false;
|
|
256
|
+
return STOP_PATTERNS.some((re) => re.test(normalized));
|
|
257
|
+
};
|
|
258
|
+
const isGenerating = Array.from(document.querySelectorAll('button, [role="button"]')).some(btn => {
|
|
259
|
+
const labels = [
|
|
260
|
+
btn.textContent || '',
|
|
261
|
+
btn.getAttribute('aria-label') || '',
|
|
262
|
+
btn.getAttribute('title') || '',
|
|
263
|
+
];
|
|
264
|
+
return labels.some(isStopLabel);
|
|
265
|
+
}) || (() => {
|
|
266
|
+
const panel = document.querySelector('.antigravity-agent-side-panel');
|
|
267
|
+
if (panel) {
|
|
268
|
+
const panelText = (panel.textContent || '').trim();
|
|
269
|
+
return /Working\.\s*$/i.test(panelText);
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
})();
|
|
273
|
+
if (isGenerating) {
|
|
274
|
+
return { detected: false, reason: "IDE is generating" };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const getInteractiveItems = (elContainer) => {
|
|
278
|
+
return Array.from(elContainer.querySelectorAll('li, label, a, [role="radio"], [role="option"], [class*="cursor-pointer"]'))
|
|
279
|
+
.filter(el => {
|
|
280
|
+
if (el.tagName === 'BUTTON' || el.closest('button')) return false;
|
|
281
|
+
const text = (el.innerText || el.textContent || '').trim();
|
|
282
|
+
if (!text) return false;
|
|
283
|
+
const role = el.getAttribute('role');
|
|
284
|
+
if (['radio', 'option', 'checkbox', 'button', 'menuitem'].includes(role)) return true;
|
|
285
|
+
if (el.tagName === 'A' || el.tagName === 'LABEL') return true;
|
|
286
|
+
const style = window.getComputedStyle(el);
|
|
287
|
+
return style.cursor === 'pointer';
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
|
|
244
291
|
const containers = Array.from(document.querySelectorAll('div, form, dialog')).reverse();
|
|
245
292
|
let targetList = null;
|
|
246
293
|
let submitBtn = null;
|
|
247
294
|
|
|
248
295
|
for (const container of containers) {
|
|
296
|
+
const hasTextInput = container.querySelector('textarea, input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]), [contenteditable], [role="textbox"], .monaco-editor, .editor-container, .inputarea, vscode-text-field, vscode-text-area');
|
|
297
|
+
const hasExplicitQuestionItems = container.querySelector('[role="radio"], input[type="radio"], input[type="checkbox"], [role="option"], [role="checkbox"]');
|
|
298
|
+
if (hasTextInput && !hasExplicitQuestionItems) continue;
|
|
249
299
|
const buttons = Array.from(container.querySelectorAll('button'));
|
|
250
300
|
let possibleSubmitBtn = null;
|
|
251
301
|
for (const btn of buttons) {
|
|
252
302
|
const text = btn.textContent?.toLowerCase() || '';
|
|
253
|
-
if (text.includes('submit')) {
|
|
303
|
+
if (text.includes('submit') || text.includes('continue')) {
|
|
254
304
|
possibleSubmitBtn = btn;
|
|
255
305
|
break;
|
|
256
306
|
}
|
|
257
307
|
}
|
|
258
308
|
|
|
259
309
|
if (possibleSubmitBtn) {
|
|
260
|
-
const items =
|
|
261
|
-
.filter(el => el.tagName !== 'BUTTON' && !el.closest('button'));
|
|
310
|
+
const items = getInteractiveItems(container);
|
|
262
311
|
|
|
263
312
|
if (items.length > 1) {
|
|
264
313
|
targetList = container;
|
|
@@ -280,8 +329,7 @@ class QuestionDetector extends events_1.EventEmitter {
|
|
|
280
329
|
}
|
|
281
330
|
const title = titleEl ? (titleEl.innerText || titleEl.textContent || '').trim() : 'Question';
|
|
282
331
|
|
|
283
|
-
const items =
|
|
284
|
-
.filter(el => el.tagName !== 'BUTTON' && !el.closest('button'));
|
|
332
|
+
const items = getInteractiveItems(targetList);
|
|
285
333
|
const options = items.map(n => {
|
|
286
334
|
const rect = n.getBoundingClientRect();
|
|
287
335
|
const finalLabel = n.innerText || n.textContent || 'Option';
|
|
@@ -321,6 +369,8 @@ class QuestionDetector extends events_1.EventEmitter {
|
|
|
321
369
|
this.logger.debug(`[QuestionDetector] Error evaluating on ctx ${contextId}: ${e.message}`);
|
|
322
370
|
return null;
|
|
323
371
|
});
|
|
372
|
+
if (!this._isStarted)
|
|
373
|
+
return;
|
|
324
374
|
if (response?.exceptionDetails) {
|
|
325
375
|
this.logger.debug(`[QuestionDetector] Exception on ctx ${contextId}: ${response.exceptionDetails.exception?.description || response.exceptionDetails.text}`);
|
|
326
376
|
}
|
|
@@ -336,6 +386,8 @@ class QuestionDetector extends events_1.EventEmitter {
|
|
|
336
386
|
lastReason = result.reason;
|
|
337
387
|
}
|
|
338
388
|
}
|
|
389
|
+
if (!this._isStarted)
|
|
390
|
+
return;
|
|
339
391
|
const result = detectedResult;
|
|
340
392
|
if (result && result.detected) {
|
|
341
393
|
this.emptyPollCount = 0;
|
|
@@ -146,6 +146,13 @@ exports.RESPONSE_SELECTORS = {
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
if (panel) {
|
|
150
|
+
const panelText = (panel.textContent || '').trim();
|
|
151
|
+
if (/Working\.\s*$/i.test(panelText)) {
|
|
152
|
+
return { isGenerating: true };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
149
156
|
return { isGenerating: false };
|
|
150
157
|
})()`,
|
|
151
158
|
/** Click stop button via tooltip-id + text fallback */
|
|
@@ -629,6 +636,7 @@ class ResponseMonitor {
|
|
|
629
636
|
onCdpReconnectFailed = null;
|
|
630
637
|
// Activity-based timeout (#49)
|
|
631
638
|
lastActivityTime = 0;
|
|
639
|
+
startTime = 0;
|
|
632
640
|
constructor(options) {
|
|
633
641
|
this.cdpService = options.cdpService;
|
|
634
642
|
this.pollIntervalMs = options.pollIntervalMs ?? 2000;
|
|
@@ -731,6 +739,7 @@ class ResponseMonitor {
|
|
|
731
739
|
}
|
|
732
740
|
// Activity-based timeout: track last activity time instead of fixed timer (#49)
|
|
733
741
|
this.lastActivityTime = Date.now();
|
|
742
|
+
this.startTime = Date.now();
|
|
734
743
|
// Register CDP connection event listeners (#48)
|
|
735
744
|
this.registerCdpConnectionListeners();
|
|
736
745
|
const mode = passive ? 'Passive monitoring' : 'Monitoring';
|
|
@@ -1109,7 +1118,14 @@ class ResponseMonitor {
|
|
|
1109
1118
|
// Guard: never timeout while the stop button is visible — it means
|
|
1110
1119
|
// Antigravity is still actively generating (extended thinking, long
|
|
1111
1120
|
// shell commands, large file operations, etc.).
|
|
1112
|
-
|
|
1121
|
+
// Hard timeout limit (20 minutes absolute hard timeout)
|
|
1122
|
+
const hardTimeoutLimit = 1200000;
|
|
1123
|
+
const totalElapsed = Date.now() - this.startTime;
|
|
1124
|
+
const isHardTimeout = totalElapsed >= hardTimeoutLimit;
|
|
1125
|
+
if (isHardTimeout || (this.maxDurationMs > 0 && !isGenerating && Date.now() - this.lastActivityTime >= this.maxDurationMs)) {
|
|
1126
|
+
if (isHardTimeout) {
|
|
1127
|
+
logger_1.logger.warn(`[ResponseMonitor] Hard timeout of 20 minutes reached.`);
|
|
1128
|
+
}
|
|
1113
1129
|
const lastText = this.lastText ?? '';
|
|
1114
1130
|
this.setPhase('timeout', lastText);
|
|
1115
1131
|
await this.stop();
|
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.ScheduleService = void 0;
|
|
37
37
|
const cron = __importStar(require("node-cron"));
|
|
38
|
+
const logger_1 = require("../utils/logger");
|
|
38
39
|
/**
|
|
39
40
|
* Service class for managing scheduled jobs.
|
|
40
41
|
*
|
|
@@ -46,9 +47,16 @@ class ScheduleService {
|
|
|
46
47
|
repo;
|
|
47
48
|
/** Map managing active cron tasks (schedule ID -> ScheduledTask) */
|
|
48
49
|
activeTasks = new Map();
|
|
50
|
+
jobCallback;
|
|
49
51
|
constructor(repo) {
|
|
50
52
|
this.repo = repo;
|
|
51
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Get the stored job callback.
|
|
56
|
+
*/
|
|
57
|
+
getJobCallback() {
|
|
58
|
+
return this.jobCallback;
|
|
59
|
+
}
|
|
52
60
|
/**
|
|
53
61
|
* Called on bot startup. Loads all enabled schedules from DB and registers/resumes them with node-cron.
|
|
54
62
|
*
|
|
@@ -56,6 +64,7 @@ class ScheduleService {
|
|
|
56
64
|
* @returns Number of restored schedules
|
|
57
65
|
*/
|
|
58
66
|
restoreAll(jobCallback) {
|
|
67
|
+
this.jobCallback = jobCallback;
|
|
59
68
|
const enabledSchedules = this.repo.findEnabled();
|
|
60
69
|
for (const schedule of enabledSchedules) {
|
|
61
70
|
this.registerCronTask(schedule, jobCallback);
|
|
@@ -73,20 +82,36 @@ class ScheduleService {
|
|
|
73
82
|
* @returns Created schedule record
|
|
74
83
|
* @throws On invalid cron expression
|
|
75
84
|
*/
|
|
76
|
-
addSchedule(cronExpression, prompt, workspacePath, jobCallback) {
|
|
85
|
+
addSchedule(cronExpression, prompt, workspacePath, channelIdOrCallback, jobCallback) {
|
|
86
|
+
let finalChannelId = '';
|
|
87
|
+
let finalJobCallback;
|
|
88
|
+
if (typeof channelIdOrCallback === 'function') {
|
|
89
|
+
finalJobCallback = channelIdOrCallback;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
finalChannelId = channelIdOrCallback;
|
|
93
|
+
finalJobCallback = jobCallback;
|
|
94
|
+
}
|
|
95
|
+
if (!finalJobCallback) {
|
|
96
|
+
throw new Error('Job callback is not initialized.');
|
|
97
|
+
}
|
|
77
98
|
// Validate cron expression
|
|
78
99
|
if (!cron.validate(cronExpression)) {
|
|
79
100
|
throw new Error(`Invalid cron expression: ${cronExpression}`);
|
|
80
101
|
}
|
|
81
102
|
// Save to DB
|
|
82
|
-
const
|
|
103
|
+
const recordInput = {
|
|
83
104
|
cronExpression,
|
|
84
105
|
prompt,
|
|
85
106
|
workspacePath,
|
|
86
107
|
enabled: true,
|
|
87
|
-
}
|
|
108
|
+
};
|
|
109
|
+
if (finalChannelId) {
|
|
110
|
+
recordInput.channelId = finalChannelId;
|
|
111
|
+
}
|
|
112
|
+
const record = this.repo.create(recordInput);
|
|
88
113
|
// Register with node-cron
|
|
89
|
-
this.registerCronTask(record,
|
|
114
|
+
this.registerCronTask(record, finalJobCallback);
|
|
90
115
|
return record;
|
|
91
116
|
}
|
|
92
117
|
/**
|
|
@@ -115,6 +140,76 @@ class ScheduleService {
|
|
|
115
140
|
}
|
|
116
141
|
this.activeTasks.clear();
|
|
117
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Remove all scheduled tasks.
|
|
145
|
+
* Stops all active cron tasks in memory, empties the database table,
|
|
146
|
+
* and resets the autoincrement ID back to 0.
|
|
147
|
+
*/
|
|
148
|
+
resetSchedules() {
|
|
149
|
+
this.repo.reset();
|
|
150
|
+
this.stopAll();
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Export all schedules as a JSON string
|
|
154
|
+
*/
|
|
155
|
+
backupSchedules() {
|
|
156
|
+
const list = this.listSchedules();
|
|
157
|
+
// Export only portable fields (exclude autoincrement ID and timestamps)
|
|
158
|
+
const portable = list.map(s => ({
|
|
159
|
+
cronExpression: s.cronExpression,
|
|
160
|
+
prompt: s.prompt,
|
|
161
|
+
workspacePath: s.workspacePath,
|
|
162
|
+
channelId: s.channelId,
|
|
163
|
+
enabled: s.enabled
|
|
164
|
+
}));
|
|
165
|
+
return JSON.stringify(portable, null, 2);
|
|
166
|
+
}
|
|
167
|
+
restoreSchedules(jsonContent, jobCallback) {
|
|
168
|
+
if (!jobCallback) {
|
|
169
|
+
throw new Error('Job callback is not initialized.');
|
|
170
|
+
}
|
|
171
|
+
let parsed;
|
|
172
|
+
try {
|
|
173
|
+
parsed = JSON.parse(jsonContent);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
throw new Error('Invalid backup format: root must be an array of schedule objects.');
|
|
177
|
+
}
|
|
178
|
+
if (!Array.isArray(parsed)) {
|
|
179
|
+
throw new Error('Invalid backup format: root must be an array of schedule objects.');
|
|
180
|
+
}
|
|
181
|
+
// Validate items
|
|
182
|
+
const validated = [];
|
|
183
|
+
for (const item of parsed) {
|
|
184
|
+
if (!item || typeof item !== 'object') {
|
|
185
|
+
throw new Error('Invalid backup format: each schedule must contain cronExpression, prompt, and workspacePath.');
|
|
186
|
+
}
|
|
187
|
+
if (typeof item.cronExpression !== 'string' || typeof item.prompt !== 'string' || typeof item.workspacePath !== 'string') {
|
|
188
|
+
throw new Error('Invalid backup format: each schedule must contain cronExpression, prompt, and workspacePath.');
|
|
189
|
+
}
|
|
190
|
+
if (!cron.validate(item.cronExpression)) {
|
|
191
|
+
throw new Error(`Invalid cron expression in backup: "${item.cronExpression}"`);
|
|
192
|
+
}
|
|
193
|
+
validated.push({
|
|
194
|
+
cronExpression: item.cronExpression,
|
|
195
|
+
prompt: item.prompt,
|
|
196
|
+
workspacePath: item.workspacePath,
|
|
197
|
+
channelId: typeof item.channelId === 'string' ? item.channelId : undefined,
|
|
198
|
+
enabled: typeof item.enabled === 'boolean' ? item.enabled : true
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// Write to DB first (atomic transaction)
|
|
202
|
+
const restoredRecords = this.repo.bulkRestore(validated);
|
|
203
|
+
// Stop memory crons now that DB write succeeded
|
|
204
|
+
this.stopAll();
|
|
205
|
+
// Resume crons in memory
|
|
206
|
+
for (const record of restoredRecords) {
|
|
207
|
+
if (record.enabled) {
|
|
208
|
+
this.registerCronTask(record, jobCallback);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return restoredRecords.length;
|
|
212
|
+
}
|
|
118
213
|
/**
|
|
119
214
|
* Get a list of all schedules
|
|
120
215
|
*/
|
|
@@ -125,7 +220,9 @@ class ScheduleService {
|
|
|
125
220
|
* Internal method to register a task with node-cron
|
|
126
221
|
*/
|
|
127
222
|
registerCronTask(schedule, jobCallback) {
|
|
223
|
+
logger_1.logger.info(`[Schedule] Registering cron task ID ${schedule.id} with expression "${schedule.cronExpression}"`);
|
|
128
224
|
const task = cron.schedule(schedule.cronExpression, () => {
|
|
225
|
+
logger_1.logger.info(`[Schedule] Cron trigger fired for task ID ${schedule.id}`);
|
|
129
226
|
jobCallback(schedule);
|
|
130
227
|
});
|
|
131
228
|
this.activeTasks.set(schedule.id, task);
|
|
@@ -113,6 +113,10 @@ function mergeConfig(persisted) {
|
|
|
113
113
|
if (platforms.includes('telegram') && !telegramToken) {
|
|
114
114
|
throw new Error('TELEGRAM_BOT_TOKEN is required when platforms include "telegram"');
|
|
115
115
|
}
|
|
116
|
+
const heartbeatEnabled = resolveBoolean(process.env.HEARTBEAT_ENABLED, persisted.heartbeatEnabled, false);
|
|
117
|
+
const heartbeatIntervalMs = resolvePositiveInt(process.env.HEARTBEAT_INTERVAL_MS, persisted.heartbeatIntervalMs, 3600000);
|
|
118
|
+
const heartbeatChannelId = process.env.HEARTBEAT_CHANNEL_ID ?? persisted.heartbeatChannelId ?? undefined;
|
|
119
|
+
const heartbeatLastMessageId = persisted.heartbeatLastMessageId ?? undefined;
|
|
116
120
|
return {
|
|
117
121
|
discordToken,
|
|
118
122
|
clientId,
|
|
@@ -128,6 +132,10 @@ function mergeConfig(persisted) {
|
|
|
128
132
|
telegramAllowedUserIds,
|
|
129
133
|
platforms,
|
|
130
134
|
cdpHost,
|
|
135
|
+
heartbeatEnabled,
|
|
136
|
+
heartbeatIntervalMs,
|
|
137
|
+
heartbeatChannelId,
|
|
138
|
+
heartbeatLastMessageId,
|
|
131
139
|
};
|
|
132
140
|
}
|
|
133
141
|
function resolveAllowedUserIds(persisted) {
|
|
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.fileOpenCache = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* In-memory cache to map button customIds to file URLs.
|
|
6
|
-
* Bounded to a maximum size to prevent memory leaks,
|
|
6
|
+
* Bounded to a maximum size to prevent memory leaks, using a FIFO eviction policy when the cache size limit is reached.
|
|
7
7
|
*/
|
|
8
8
|
class BoundedCache {
|
|
9
9
|
max_size;
|
|
@@ -23,11 +23,7 @@ class BoundedCache {
|
|
|
23
23
|
this.map.set(key, value);
|
|
24
24
|
}
|
|
25
25
|
get(key) {
|
|
26
|
-
|
|
27
|
-
if (value !== undefined) {
|
|
28
|
-
this.map.delete(key); // Remove upon consumption
|
|
29
|
-
}
|
|
30
|
-
return value;
|
|
26
|
+
return this.map.get(key);
|
|
31
27
|
}
|
|
32
28
|
}
|
|
33
29
|
exports.fileOpenCache = new BoundedCache(1000);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.parseQuestionCustomId = parseQuestionCustomId;
|
|
4
|
+
exports.executeBrowserClick = executeBrowserClick;
|
|
5
|
+
const logger_1 = require("./logger");
|
|
6
|
+
const approvalDetector_1 = require("../services/approvalDetector");
|
|
4
7
|
function parseQuestionCustomId(customId, expectedPrefix) {
|
|
5
8
|
if (customId !== expectedPrefix && !customId.startsWith(expectedPrefix + ':'))
|
|
6
9
|
return null;
|
|
@@ -23,3 +26,22 @@ function parseQuestionCustomId(customId, expectedPrefix) {
|
|
|
23
26
|
}
|
|
24
27
|
return result;
|
|
25
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Executes a click on an element matching the target text in the IDE.
|
|
31
|
+
* Returns true if the click was successfully dispatched, false otherwise.
|
|
32
|
+
*/
|
|
33
|
+
async function executeBrowserClick(cdp, buttonText) {
|
|
34
|
+
try {
|
|
35
|
+
const script = (0, approvalDetector_1.buildClickScript)(buttonText);
|
|
36
|
+
const evalResult = await cdp.call('Runtime.evaluate', {
|
|
37
|
+
expression: script,
|
|
38
|
+
returnByValue: true,
|
|
39
|
+
awaitPromise: true,
|
|
40
|
+
});
|
|
41
|
+
return !!evalResult?.result?.value?.ok;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
logger_1.logger.error(`[executeBrowserClick] Error clicking button "${buttonText}":`, error);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazy-gravity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Control Antigravity from anywhere — a local, secure bot (Discord + Telegram) that lets you remotely operate Antigravity on your home PC from your smartphone.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"@inquirer/select": "^5.1.0",
|
|
51
51
|
"better-sqlite3": "^12.6.2",
|
|
52
52
|
"commander": "^15.0.0",
|
|
53
|
+
"cron-parser": "^5.6.1",
|
|
53
54
|
"discord.js": "^14.25.1",
|
|
54
55
|
"dotenv": "^17.3.1",
|
|
55
56
|
"grammy": "^1.41.1",
|