@traisetech/autopilot 2.4.0 → 2.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/CHANGELOG.md +25 -9
- package/README.md +215 -106
- package/bin/autopilot.js +1 -1
- package/docs/CONFIGURATION.md +103 -103
- package/docs/DESIGN_PRINCIPLES.md +114 -114
- package/docs/TEAM-MODE.md +51 -51
- package/docs/TROUBLESHOOTING.md +21 -21
- package/package.json +75 -69
- package/src/commands/config.js +110 -110
- package/src/commands/dashboard.mjs +151 -151
- package/src/commands/doctor.js +127 -153
- package/src/commands/init.js +8 -9
- package/src/commands/insights.js +237 -237
- package/src/commands/leaderboard.js +116 -116
- package/src/commands/pause.js +18 -18
- package/src/commands/preset.js +121 -121
- package/src/commands/resume.js +17 -17
- package/src/commands/start.js +41 -41
- package/src/commands/status.js +73 -39
- package/src/commands/stop.js +58 -50
- package/src/commands/undo.js +84 -84
- package/src/config/defaults.js +23 -16
- package/src/config/ignore.js +14 -31
- package/src/config/loader.js +80 -80
- package/src/core/commit.js +45 -52
- package/src/core/commitMessageGenerator.js +130 -0
- package/src/core/configValidator.js +92 -0
- package/src/core/events.js +110 -110
- package/src/core/focus.js +2 -1
- package/src/core/gemini.js +15 -15
- package/src/core/git.js +29 -2
- package/src/core/history.js +69 -69
- package/src/core/notifier.js +61 -0
- package/src/core/retryQueue.js +152 -0
- package/src/core/safety.js +224 -210
- package/src/core/state.js +69 -71
- package/src/core/watcher.js +193 -66
- package/src/index.js +70 -70
- package/src/utils/banner.js +6 -6
- package/src/utils/crypto.js +18 -18
- package/src/utils/identity.js +41 -41
- package/src/utils/logger.js +86 -68
- package/src/utils/paths.js +62 -62
- package/src/utils/process.js +141 -141
package/src/core/events.js
CHANGED
|
@@ -1,110 +1,110 @@
|
|
|
1
|
-
const fs = require('fs-extra');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const { getConfigDir } = require('../utils/paths');
|
|
4
|
-
const logger = require('../utils/logger');
|
|
5
|
-
|
|
6
|
-
const getQueuePath = () => path.join(getConfigDir(), 'events-queue.json');
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Telemetry/Event System for Autopilot
|
|
10
|
-
* Handles reliable event emission with local queuing
|
|
11
|
-
*/
|
|
12
|
-
class EventSystem {
|
|
13
|
-
constructor() {
|
|
14
|
-
this.queue = [];
|
|
15
|
-
this.isFlushing = false;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async loadQueue() {
|
|
19
|
-
try {
|
|
20
|
-
const queuePath = getQueuePath();
|
|
21
|
-
if (await fs.pathExists(queuePath)) {
|
|
22
|
-
this.queue = await fs.readJson(queuePath);
|
|
23
|
-
}
|
|
24
|
-
} catch (error) {
|
|
25
|
-
logger.debug(`Failed to load event queue: ${error.message}`);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async saveQueue() {
|
|
30
|
-
try {
|
|
31
|
-
await fs.ensureDir(getConfigDir());
|
|
32
|
-
await fs.writeJson(getQueuePath(), this.queue, { spaces: 2 });
|
|
33
|
-
} catch (error) {
|
|
34
|
-
logger.error(`Failed to save event queue: ${error.message}`);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Queue an event for emission
|
|
40
|
-
* @param {object} event - Event payload
|
|
41
|
-
*/
|
|
42
|
-
async emit(event) {
|
|
43
|
-
await this.loadQueue();
|
|
44
|
-
|
|
45
|
-
// Add metadata
|
|
46
|
-
const enrichedEvent = {
|
|
47
|
-
...event,
|
|
48
|
-
queuedAt: Date.now(),
|
|
49
|
-
retryCount: 0
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
this.queue.push(enrichedEvent);
|
|
53
|
-
await this.saveQueue();
|
|
54
|
-
|
|
55
|
-
// Try to flush immediately
|
|
56
|
-
this.flush();
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Attempt to send queued events to backend
|
|
61
|
-
*/
|
|
62
|
-
async flush() {
|
|
63
|
-
if (this.isFlushing || this.queue.length === 0) return;
|
|
64
|
-
this.isFlushing = true;
|
|
65
|
-
|
|
66
|
-
try {
|
|
67
|
-
const remaining = [];
|
|
68
|
-
|
|
69
|
-
for (const event of this.queue) {
|
|
70
|
-
try {
|
|
71
|
-
await this.sendToBackend(event);
|
|
72
|
-
} catch (error) {
|
|
73
|
-
// Keep in queue if failed
|
|
74
|
-
event.retryCount++;
|
|
75
|
-
remaining.push(event);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
this.queue = remaining;
|
|
80
|
-
await this.saveQueue();
|
|
81
|
-
} finally {
|
|
82
|
-
this.isFlushing = false;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Mock backend transmission
|
|
88
|
-
* In production, this would POST to an API
|
|
89
|
-
*/
|
|
90
|
-
async sendToBackend(event) {
|
|
91
|
-
const apiBase = process.env.AUTOPILOT_API_URL || 'https://autopilot-cli.vercel.app';
|
|
92
|
-
const url = `${apiBase}/api/events`;
|
|
93
|
-
const controller = new AbortController();
|
|
94
|
-
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
95
|
-
try {
|
|
96
|
-
const res = await fetch(url, {
|
|
97
|
-
method: 'POST',
|
|
98
|
-
headers: { 'Content-Type': 'application/json' },
|
|
99
|
-
body: JSON.stringify(event),
|
|
100
|
-
signal: controller.signal
|
|
101
|
-
});
|
|
102
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
103
|
-
return true;
|
|
104
|
-
} finally {
|
|
105
|
-
clearTimeout(timeout);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
module.exports = new EventSystem();
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { getConfigDir } = require('../utils/paths');
|
|
4
|
+
const logger = require('../utils/logger');
|
|
5
|
+
|
|
6
|
+
const getQueuePath = () => path.join(getConfigDir(), 'events-queue.json');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Telemetry/Event System for Autopilot
|
|
10
|
+
* Handles reliable event emission with local queuing
|
|
11
|
+
*/
|
|
12
|
+
class EventSystem {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.queue = [];
|
|
15
|
+
this.isFlushing = false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async loadQueue() {
|
|
19
|
+
try {
|
|
20
|
+
const queuePath = getQueuePath();
|
|
21
|
+
if (await fs.pathExists(queuePath)) {
|
|
22
|
+
this.queue = await fs.readJson(queuePath);
|
|
23
|
+
}
|
|
24
|
+
} catch (error) {
|
|
25
|
+
logger.debug(`Failed to load event queue: ${error.message}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async saveQueue() {
|
|
30
|
+
try {
|
|
31
|
+
await fs.ensureDir(getConfigDir());
|
|
32
|
+
await fs.writeJson(getQueuePath(), this.queue, { spaces: 2 });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
logger.error(`Failed to save event queue: ${error.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Queue an event for emission
|
|
40
|
+
* @param {object} event - Event payload
|
|
41
|
+
*/
|
|
42
|
+
async emit(event) {
|
|
43
|
+
await this.loadQueue();
|
|
44
|
+
|
|
45
|
+
// Add metadata
|
|
46
|
+
const enrichedEvent = {
|
|
47
|
+
...event,
|
|
48
|
+
queuedAt: Date.now(),
|
|
49
|
+
retryCount: 0
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
this.queue.push(enrichedEvent);
|
|
53
|
+
await this.saveQueue();
|
|
54
|
+
|
|
55
|
+
// Try to flush immediately
|
|
56
|
+
this.flush();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Attempt to send queued events to backend
|
|
61
|
+
*/
|
|
62
|
+
async flush() {
|
|
63
|
+
if (this.isFlushing || this.queue.length === 0) return;
|
|
64
|
+
this.isFlushing = true;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const remaining = [];
|
|
68
|
+
|
|
69
|
+
for (const event of this.queue) {
|
|
70
|
+
try {
|
|
71
|
+
await this.sendToBackend(event);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// Keep in queue if failed
|
|
74
|
+
event.retryCount++;
|
|
75
|
+
remaining.push(event);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.queue = remaining;
|
|
80
|
+
await this.saveQueue();
|
|
81
|
+
} finally {
|
|
82
|
+
this.isFlushing = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Mock backend transmission
|
|
88
|
+
* In production, this would POST to an API
|
|
89
|
+
*/
|
|
90
|
+
async sendToBackend(event) {
|
|
91
|
+
const apiBase = process.env.AUTOPILOT_API_URL || 'https://autopilot-cli.vercel.app';
|
|
92
|
+
const url = `${apiBase}/api/events`;
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch(url, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'Content-Type': 'application/json' },
|
|
99
|
+
body: JSON.stringify(event),
|
|
100
|
+
signal: controller.signal
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
103
|
+
return true;
|
|
104
|
+
} finally {
|
|
105
|
+
clearTimeout(timeout);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = new EventSystem();
|
package/src/core/focus.js
CHANGED
|
@@ -12,7 +12,8 @@ const CalendarIntegration = require('../integrations/calendar');
|
|
|
12
12
|
class FocusEngine {
|
|
13
13
|
constructor(repoPath, config) {
|
|
14
14
|
this.repoPath = repoPath;
|
|
15
|
-
this.logFile = path.join(repoPath, 'autopilot.log');
|
|
15
|
+
this.logFile = path.join(repoPath, '.autopilot', 'focus.log');
|
|
16
|
+
fs.ensureDirSync(path.dirname(this.logFile));
|
|
16
17
|
this.config = config?.focus || {
|
|
17
18
|
activeThresholdSeconds: 120, // 2 mins between events counts as continuous active time
|
|
18
19
|
sessionTimeoutSeconds: 1800, // 30 mins gap = new session
|
package/src/core/gemini.js
CHANGED
|
@@ -41,10 +41,10 @@ ${truncatedDiff}
|
|
|
41
41
|
`;
|
|
42
42
|
|
|
43
43
|
try {
|
|
44
|
-
const url = `${BASE_API_URL}${model}:generateContent?key=${apiKey}`;
|
|
45
|
-
const controller = new AbortController();
|
|
46
|
-
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
47
|
-
const response = await fetch(url, {
|
|
44
|
+
const url = `${BASE_API_URL}${model}:generateContent?key=${apiKey}`;
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
47
|
+
const response = await fetch(url, {
|
|
48
48
|
method: 'POST',
|
|
49
49
|
headers: {
|
|
50
50
|
'Content-Type': 'application/json',
|
|
@@ -59,10 +59,10 @@ ${truncatedDiff}
|
|
|
59
59
|
temperature: 0.2,
|
|
60
60
|
maxOutputTokens: 256,
|
|
61
61
|
}
|
|
62
|
-
}),
|
|
63
|
-
signal: controller.signal
|
|
62
|
+
}),
|
|
63
|
+
signal: controller.signal
|
|
64
64
|
});
|
|
65
|
-
clearTimeout(timeout);
|
|
65
|
+
clearTimeout(timeout);
|
|
66
66
|
|
|
67
67
|
if (!response.ok) {
|
|
68
68
|
const errorData = await response.json().catch(() => ({}));
|
|
@@ -96,20 +96,20 @@ ${truncatedDiff}
|
|
|
96
96
|
*/
|
|
97
97
|
async function validateApiKey(apiKey, model = DEFAULT_MODEL) {
|
|
98
98
|
try {
|
|
99
|
-
const url = `${BASE_API_URL}${model}:generateContent?key=${apiKey}`;
|
|
100
|
-
// Simple test call with empty prompt
|
|
101
|
-
const controller = new AbortController();
|
|
102
|
-
const timeout = setTimeout(() => controller.abort(), 4000);
|
|
103
|
-
const response = await fetch(url, {
|
|
99
|
+
const url = `${BASE_API_URL}${model}:generateContent?key=${apiKey}`;
|
|
100
|
+
// Simple test call with empty prompt
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const timeout = setTimeout(() => controller.abort(), 4000);
|
|
103
|
+
const response = await fetch(url, {
|
|
104
104
|
method: 'POST',
|
|
105
105
|
headers: { 'Content-Type': 'application/json' },
|
|
106
106
|
body: JSON.stringify({
|
|
107
107
|
contents: [{ parts: [{ text: "Hi" }] }],
|
|
108
108
|
generationConfig: { maxOutputTokens: 1 }
|
|
109
|
-
}),
|
|
110
|
-
signal: controller.signal
|
|
109
|
+
}),
|
|
110
|
+
signal: controller.signal
|
|
111
111
|
});
|
|
112
|
-
clearTimeout(timeout);
|
|
112
|
+
clearTimeout(timeout);
|
|
113
113
|
|
|
114
114
|
if (!response.ok) {
|
|
115
115
|
const errorData = await response.json().catch(() => ({}));
|
package/src/core/git.js
CHANGED
|
@@ -151,17 +151,44 @@ async function isRemoteAhead(root) {
|
|
|
151
151
|
* Push changes to remote
|
|
152
152
|
* @param {string} root - Repository root path
|
|
153
153
|
* @param {string} [branch] - Branch to push (optional, defaults to current)
|
|
154
|
-
* @returns {Promise<{ok: boolean, stdout: string, stderr: string}>} Result object
|
|
154
|
+
* @returns {Promise<{ok: boolean, stdout: string, stderr: string, conflict?: boolean}>} Result object
|
|
155
155
|
*/
|
|
156
156
|
async function push(root, branch) {
|
|
157
157
|
try {
|
|
158
158
|
const targetBranch = branch || await getBranch(root);
|
|
159
159
|
if (!targetBranch) throw new Error('Could not determine branch to push');
|
|
160
160
|
|
|
161
|
+
// 1. Fetch updates
|
|
162
|
+
await fetch(root);
|
|
163
|
+
|
|
164
|
+
// 2. Check if behind
|
|
165
|
+
const { stdout: behindCountStr } = await execa('git', ['rev-list', 'HEAD..origin/' + targetBranch, '--count'], { cwd: root }).catch(() => ({ stdout: '0' }));
|
|
166
|
+
const behindCount = parseInt(behindCountStr.trim(), 10);
|
|
167
|
+
|
|
168
|
+
if (behindCount > 0) {
|
|
169
|
+
// 3. Try rebase
|
|
170
|
+
try {
|
|
171
|
+
await execa('git', ['pull', '--rebase', 'origin', targetBranch], { cwd: root });
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (err.stdout?.includes('CONFLICT') || err.stderr?.includes('CONFLICT')) {
|
|
174
|
+
return { ok: false, stdout: '', stderr: 'Rebase conflict detected — manual intervention required', conflict: true };
|
|
175
|
+
}
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 4. Push
|
|
161
181
|
const { stdout, stderr } = await execa('git', ['push', 'origin', targetBranch], { cwd: root });
|
|
162
182
|
return { ok: true, stdout, stderr };
|
|
163
183
|
} catch (error) {
|
|
164
|
-
|
|
184
|
+
const stderr = error.stderr || error.message || '';
|
|
185
|
+
if (stderr.includes('CONFLICT')) {
|
|
186
|
+
return { ok: false, stdout: '', stderr: 'Rebase conflict detected — manual intervention required', conflict: true };
|
|
187
|
+
}
|
|
188
|
+
if (error.exitCode === 128 && stderr.includes('rejected')) {
|
|
189
|
+
return { ok: false, stdout: '', stderr: 'Push rejected: remote has diverged. Try manual rebase.', conflict: true };
|
|
190
|
+
}
|
|
191
|
+
return { ok: false, stdout: '', stderr };
|
|
165
192
|
}
|
|
166
193
|
}
|
|
167
194
|
|
package/src/core/history.js
CHANGED
|
@@ -1,69 +1,69 @@
|
|
|
1
|
-
const fs = require('fs-extra');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const { getAutopilotHome } = require('../utils/paths');
|
|
4
|
-
const logger = require('../utils/logger');
|
|
5
|
-
|
|
6
|
-
const HISTORY_FILE = 'history.json';
|
|
7
|
-
|
|
8
|
-
class HistoryManager {
|
|
9
|
-
constructor(repoPath) {
|
|
10
|
-
this.repoPath = repoPath;
|
|
11
|
-
this.historyDir = path.join(repoPath, '.autopilot');
|
|
12
|
-
this.historyFile = path.join(this.historyDir, HISTORY_FILE);
|
|
13
|
-
this.init();
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
init() {
|
|
17
|
-
fs.ensureDirSync(this.historyDir);
|
|
18
|
-
if (!fs.existsSync(this.historyFile)) {
|
|
19
|
-
fs.writeJsonSync(this.historyFile, { commits: [] }, { spaces: 2 });
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
getHistory() {
|
|
24
|
-
try {
|
|
25
|
-
const data = fs.readJsonSync(this.historyFile);
|
|
26
|
-
return data.commits || [];
|
|
27
|
-
} catch (error) {
|
|
28
|
-
logger.error('Failed to read history:', error.message);
|
|
29
|
-
return [];
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
addCommit(commitData) {
|
|
34
|
-
try {
|
|
35
|
-
const history = this.getHistory();
|
|
36
|
-
history.unshift({
|
|
37
|
-
...commitData,
|
|
38
|
-
timestamp: new Date().toISOString()
|
|
39
|
-
});
|
|
40
|
-
// Keep only last 100 commits
|
|
41
|
-
if (history.length > 100) history.length = 100;
|
|
42
|
-
|
|
43
|
-
fs.writeJsonSync(this.historyFile, { commits: history }, { spaces: 2 });
|
|
44
|
-
} catch (error) {
|
|
45
|
-
logger.error('Failed to write history:', error.message);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
getLastCommit() {
|
|
50
|
-
const history = this.getHistory();
|
|
51
|
-
return history[0];
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
removeLastCommit() {
|
|
55
|
-
try {
|
|
56
|
-
const history = this.getHistory();
|
|
57
|
-
if (history.length === 0) return null;
|
|
58
|
-
|
|
59
|
-
const removed = history.shift();
|
|
60
|
-
fs.writeJsonSync(this.historyFile, { commits: history }, { spaces: 2 });
|
|
61
|
-
return removed;
|
|
62
|
-
} catch (error) {
|
|
63
|
-
logger.error('Failed to update history:', error.message);
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
module.exports = HistoryManager;
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { getAutopilotHome } = require('../utils/paths');
|
|
4
|
+
const logger = require('../utils/logger');
|
|
5
|
+
|
|
6
|
+
const HISTORY_FILE = 'history.json';
|
|
7
|
+
|
|
8
|
+
class HistoryManager {
|
|
9
|
+
constructor(repoPath) {
|
|
10
|
+
this.repoPath = repoPath;
|
|
11
|
+
this.historyDir = path.join(repoPath, '.autopilot');
|
|
12
|
+
this.historyFile = path.join(this.historyDir, HISTORY_FILE);
|
|
13
|
+
this.init();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
init() {
|
|
17
|
+
fs.ensureDirSync(this.historyDir);
|
|
18
|
+
if (!fs.existsSync(this.historyFile)) {
|
|
19
|
+
fs.writeJsonSync(this.historyFile, { commits: [] }, { spaces: 2 });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
getHistory() {
|
|
24
|
+
try {
|
|
25
|
+
const data = fs.readJsonSync(this.historyFile);
|
|
26
|
+
return data.commits || [];
|
|
27
|
+
} catch (error) {
|
|
28
|
+
logger.error('Failed to read history:', error.message);
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
addCommit(commitData) {
|
|
34
|
+
try {
|
|
35
|
+
const history = this.getHistory();
|
|
36
|
+
history.unshift({
|
|
37
|
+
...commitData,
|
|
38
|
+
timestamp: new Date().toISOString()
|
|
39
|
+
});
|
|
40
|
+
// Keep only last 100 commits
|
|
41
|
+
if (history.length > 100) history.length = 100;
|
|
42
|
+
|
|
43
|
+
fs.writeJsonSync(this.historyFile, { commits: history }, { spaces: 2 });
|
|
44
|
+
} catch (error) {
|
|
45
|
+
logger.error('Failed to write history:', error.message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getLastCommit() {
|
|
50
|
+
const history = this.getHistory();
|
|
51
|
+
return history[0];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
removeLastCommit() {
|
|
55
|
+
try {
|
|
56
|
+
const history = this.getHistory();
|
|
57
|
+
if (history.length === 0) return null;
|
|
58
|
+
|
|
59
|
+
const removed = history.shift();
|
|
60
|
+
fs.writeJsonSync(this.historyFile, { commits: history }, { spaces: 2 });
|
|
61
|
+
return removed;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
logger.error('Failed to update history:', error.message);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = HistoryManager;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System notifications for Autopilot events
|
|
3
|
+
* Built by Praise Masunga (PraiseTechzw)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const notifier = require('node-notifier');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Send a desktop notification
|
|
11
|
+
* @param {string} event - The name/type of the event
|
|
12
|
+
* @param {object} data - The event data { message, branch, title, sound }
|
|
13
|
+
* @param {boolean} [enabled=true] - Whether notifications are enabled in config
|
|
14
|
+
*/
|
|
15
|
+
function notify(event, data, enabled = true) {
|
|
16
|
+
if (!enabled) return;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
let title = data.title || 'Autopilot';
|
|
20
|
+
let message = data.message || '';
|
|
21
|
+
let sound = data.sound || false;
|
|
22
|
+
|
|
23
|
+
switch (event) {
|
|
24
|
+
case 'push_success':
|
|
25
|
+
title = 'Autopilot';
|
|
26
|
+
message = `Pushed: ${data.commitMessage || 'latest changes'}`;
|
|
27
|
+
sound = false;
|
|
28
|
+
break;
|
|
29
|
+
case 'push_failed':
|
|
30
|
+
title = 'Autopilot';
|
|
31
|
+
message = 'Push failed — queued for retry';
|
|
32
|
+
sound = false;
|
|
33
|
+
break;
|
|
34
|
+
case 'conflict':
|
|
35
|
+
title = 'Autopilot — Action needed';
|
|
36
|
+
message = `Merge conflict in ${data.branch || 'current branch'} — paused`;
|
|
37
|
+
sound = true;
|
|
38
|
+
break;
|
|
39
|
+
case 'queue_cleared':
|
|
40
|
+
title = 'Autopilot';
|
|
41
|
+
message = 'All queued pushes succeeded';
|
|
42
|
+
sound = false;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
notifier.notify({
|
|
47
|
+
title,
|
|
48
|
+
message,
|
|
49
|
+
sound,
|
|
50
|
+
// Optional icon (use relative from src/core to some generic icon location if it exists)
|
|
51
|
+
// icon: path.join(__dirname, '../assets/icon.png')
|
|
52
|
+
});
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// Never crash the watcher if notifications fail
|
|
55
|
+
// console.error('Notification failed:', error);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
notify
|
|
61
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network retry queue for failed pushes
|
|
3
|
+
* Built by Praise Masunga (PraiseTechzw)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs-extra');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const logger = require('../utils/logger');
|
|
9
|
+
|
|
10
|
+
class RetryQueue {
|
|
11
|
+
/**
|
|
12
|
+
* Initialize retry queue
|
|
13
|
+
* @param {string} root - Repository root path
|
|
14
|
+
* @param {Function} pushFn - Function to call for retry (expects root, branch)
|
|
15
|
+
*/
|
|
16
|
+
constructor(root, pushFn) {
|
|
17
|
+
this.root = root;
|
|
18
|
+
this.queuePath = path.join(root, '.autopilot-queue.json');
|
|
19
|
+
this.pushFn = pushFn;
|
|
20
|
+
this.queue = [];
|
|
21
|
+
this.retryDelays = [30000, 60000, 120000, 300000, 600000]; // 30s, 60s, 2m, 5m, 10m
|
|
22
|
+
|
|
23
|
+
this.load();
|
|
24
|
+
this.initRetries();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Load existing queue from file
|
|
29
|
+
*/
|
|
30
|
+
load() {
|
|
31
|
+
try {
|
|
32
|
+
if (fs.existsSync(this.queuePath)) {
|
|
33
|
+
this.queue = fs.readJsonSync(this.queuePath);
|
|
34
|
+
// Reset timers/attempts if loaded from disk?
|
|
35
|
+
// Actually we should resume where it left off, but we'll re-init timers.
|
|
36
|
+
}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
logger.error('Failed to load retry queue:', err.message);
|
|
39
|
+
this.queue = [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Persist current queue to file
|
|
45
|
+
*/
|
|
46
|
+
save() {
|
|
47
|
+
try {
|
|
48
|
+
fs.writeJsonSync(this.queuePath, this.queue, { spaces: 2 });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
logger.error('Failed to save retry queue:', err.message);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Add a new push job to the queue
|
|
56
|
+
* @param {object} job - { commitHash, branch, timestamp, attempts, maxAttempts }
|
|
57
|
+
*/
|
|
58
|
+
add(job) {
|
|
59
|
+
const newJob = {
|
|
60
|
+
...job,
|
|
61
|
+
timestamp: Date.now(),
|
|
62
|
+
attempts: job.attempts || 0,
|
|
63
|
+
maxAttempts: job.maxAttempts || 5
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Check if duplicate?
|
|
67
|
+
if (this.queue.some(j => j.commitHash === job.commitHash)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.queue.push(newJob);
|
|
72
|
+
this.save();
|
|
73
|
+
|
|
74
|
+
// Start retry for this job
|
|
75
|
+
this.scheduleRetry(newJob);
|
|
76
|
+
logger.info(`Push queued — will retry later (Job: ${newJob.commitHash})`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Cancel a pending job
|
|
81
|
+
* @param {string} commitHash
|
|
82
|
+
*/
|
|
83
|
+
cancel(commitHash) {
|
|
84
|
+
this.queue = this.queue.filter(j => j.commitHash !== commitHash);
|
|
85
|
+
this.save();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Schedule a retry for a specific job
|
|
90
|
+
* @param {object} job
|
|
91
|
+
*/
|
|
92
|
+
scheduleRetry(job) {
|
|
93
|
+
const delay = this.retryDelays[job.attempts] || 600000;
|
|
94
|
+
|
|
95
|
+
setTimeout(async () => {
|
|
96
|
+
// Re-fetch job from queue to see if it hasn't been cancelled or already succeeded
|
|
97
|
+
const currentJob = this.queue.find(j => j.commitHash === job.commitHash);
|
|
98
|
+
if (!currentJob) return;
|
|
99
|
+
|
|
100
|
+
currentJob.attempts++;
|
|
101
|
+
logger.info(`Retrying queued push (Attempt ${currentJob.attempts}/${currentJob.maxAttempts}) for ${currentJob.commitHash}`);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const result = await this.pushFn(this.root, currentJob.branch);
|
|
105
|
+
if (result.ok) {
|
|
106
|
+
logger.info(`Queued push succeeded on attempt ${currentJob.attempts} for ${currentJob.commitHash}`);
|
|
107
|
+
this.cancel(currentJob.commitHash);
|
|
108
|
+
if (this.queue.length === 0) {
|
|
109
|
+
// Emit queue cleared event
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
if (currentJob.attempts >= currentJob.maxAttempts) {
|
|
113
|
+
logger.error(`Max retry attempts exceeded for ${currentJob.commitHash}. Job removed from queue.`);
|
|
114
|
+
this.cancel(currentJob.commitHash);
|
|
115
|
+
} else {
|
|
116
|
+
this.save(); // Update attempt count on disk
|
|
117
|
+
this.scheduleRetry(currentJob); // Continue backoff
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logger.error(`Retry error for ${currentJob.commitHash}: ${error.message}`);
|
|
122
|
+
this.scheduleRetry(currentJob);
|
|
123
|
+
}
|
|
124
|
+
}, delay);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Initialize retries for all pending jobs in the queue (e.g. after restart)
|
|
129
|
+
*/
|
|
130
|
+
initRetries() {
|
|
131
|
+
if (this.queue.length > 0) {
|
|
132
|
+
logger.info(`Resuming ${this.queue.length} pending push jobs from queue...`);
|
|
133
|
+
this.queue.forEach(job => this.scheduleRetry(job));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get public status of the queue
|
|
139
|
+
* @returns {Array}
|
|
140
|
+
*/
|
|
141
|
+
getStatus() {
|
|
142
|
+
return this.queue.map(j => ({
|
|
143
|
+
commitHash: j.commitHash,
|
|
144
|
+
branch: j.branch,
|
|
145
|
+
attempts: j.attempts,
|
|
146
|
+
maxAttempts: j.maxAttempts,
|
|
147
|
+
queuedAt: new Date(j.timestamp).toISOString()
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = RetryQueue;
|