@traisetech/autopilot 2.3.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/README.md +215 -202
  3. package/bin/autopilot.js +9 -2
  4. package/docs/CONFIGURATION.md +103 -103
  5. package/docs/DESIGN_PRINCIPLES.md +114 -114
  6. package/docs/TEAM-MODE.md +51 -51
  7. package/docs/TROUBLESHOOTING.md +21 -21
  8. package/package.json +75 -69
  9. package/src/commands/config.js +110 -110
  10. package/src/commands/dashboard.mjs +151 -151
  11. package/src/commands/doctor.js +127 -153
  12. package/src/commands/guide.js +63 -0
  13. package/src/commands/init.js +8 -9
  14. package/src/commands/insights.js +237 -237
  15. package/src/commands/leaderboard.js +116 -116
  16. package/src/commands/pause.js +18 -18
  17. package/src/commands/preset.js +121 -121
  18. package/src/commands/resume.js +17 -17
  19. package/src/commands/start.js +41 -41
  20. package/src/commands/status.js +73 -39
  21. package/src/commands/stop.js +58 -50
  22. package/src/commands/undo.js +84 -84
  23. package/src/config/defaults.js +23 -16
  24. package/src/config/ignore.js +14 -31
  25. package/src/config/loader.js +80 -80
  26. package/src/core/commit.js +45 -52
  27. package/src/core/commitMessageGenerator.js +130 -0
  28. package/src/core/configValidator.js +92 -0
  29. package/src/core/events.js +110 -110
  30. package/src/core/focus.js +2 -1
  31. package/src/core/gemini.js +15 -15
  32. package/src/core/git.js +29 -2
  33. package/src/core/history.js +69 -69
  34. package/src/core/notifier.js +61 -0
  35. package/src/core/retryQueue.js +152 -0
  36. package/src/core/safety.js +224 -210
  37. package/src/core/state.js +69 -71
  38. package/src/core/watcher.js +193 -66
  39. package/src/index.js +70 -70
  40. package/src/utils/banner.js +6 -6
  41. package/src/utils/crypto.js +18 -18
  42. package/src/utils/identity.js +41 -41
  43. package/src/utils/logger.js +86 -68
  44. package/src/utils/paths.js +62 -62
  45. package/src/utils/process.js +141 -141
@@ -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
@@ -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
- return { ok: false, stdout: '', stderr: error.message };
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
 
@@ -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;