nightytidy 0.2.2 → 0.2.4
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 +2 -2
- package/package.json +1 -1
- package/src/agent/firebase-auth.js +84 -7
- package/src/agent/index.js +107 -113
- package/src/agent/websocket-server.js +3 -2
package/README.md
CHANGED
|
@@ -38,10 +38,10 @@ The agent runs locally at `127.0.0.1:48372`. The web app connects to it via WebS
|
|
|
38
38
|
|
|
39
39
|
### Desktop GUI (local)
|
|
40
40
|
|
|
41
|
-
A Chrome app-mode window for fully local use — no account needed:
|
|
41
|
+
A Chrome app-mode window for fully local use — no account needed. Clone the repo and run:
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
-
|
|
44
|
+
npm run gui
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
From there:
|
package/package.json
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { debug, info, warn } from '../logger.js';
|
|
2
2
|
|
|
3
3
|
const REFRESH_BUFFER_MS = 15 * 60_000; // Request refresh 15 min before expiry
|
|
4
|
+
const MAX_BACKOFF_MS = 4 * 60_000; // Cap retry backoff at 4 minutes
|
|
5
|
+
const MAX_QUEUED_WEBHOOKS = 200; // Prevent unbounded queue growth
|
|
4
6
|
|
|
5
7
|
export class FirebaseAuth {
|
|
6
8
|
constructor(configDir) {
|
|
@@ -8,6 +10,26 @@ export class FirebaseAuth {
|
|
|
8
10
|
this.token = null;
|
|
9
11
|
this.expiresAt = null;
|
|
10
12
|
this._refreshRequested = false;
|
|
13
|
+
this._refreshAttempts = 0;
|
|
14
|
+
this._refreshTimer = null;
|
|
15
|
+
this._pendingWebhooks = [];
|
|
16
|
+
this._replayCallback = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse the `exp` claim from a Firebase ID token (standard JWT).
|
|
21
|
+
* Returns expiry as milliseconds since epoch, or null on failure.
|
|
22
|
+
* No crypto needed — we only read the unverified payload for timing.
|
|
23
|
+
*/
|
|
24
|
+
static parseJwtExpiry(token) {
|
|
25
|
+
try {
|
|
26
|
+
const parts = token.split('.');
|
|
27
|
+
if (parts.length !== 3) return null;
|
|
28
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
29
|
+
return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
11
33
|
}
|
|
12
34
|
|
|
13
35
|
isAuthenticated() {
|
|
@@ -19,11 +41,24 @@ export class FirebaseAuth {
|
|
|
19
41
|
return this.token;
|
|
20
42
|
}
|
|
21
43
|
|
|
22
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Store a Firebase ID token. Expiry is parsed from the JWT's `exp` claim
|
|
46
|
+
* rather than assuming 1 hour from now — the token may have been minted
|
|
47
|
+
* well before the agent received it.
|
|
48
|
+
*/
|
|
49
|
+
setToken(token) {
|
|
50
|
+
const jwtExpiry = FirebaseAuth.parseJwtExpiry(token);
|
|
23
51
|
this.token = token;
|
|
24
|
-
this.expiresAt =
|
|
52
|
+
this.expiresAt = jwtExpiry || (Date.now() + 3600_000);
|
|
25
53
|
this._refreshRequested = false;
|
|
26
|
-
|
|
54
|
+
this._refreshAttempts = 0;
|
|
55
|
+
if (this._refreshTimer) {
|
|
56
|
+
clearTimeout(this._refreshTimer);
|
|
57
|
+
this._refreshTimer = null;
|
|
58
|
+
}
|
|
59
|
+
const remainMin = Math.round((this.expiresAt - Date.now()) / 60_000);
|
|
60
|
+
debug(`Firebase auth token updated (expires in ${remainMin}m)`);
|
|
61
|
+
this._replayQueue();
|
|
27
62
|
}
|
|
28
63
|
|
|
29
64
|
getAuthHeader() {
|
|
@@ -34,7 +69,8 @@ export class FirebaseAuth {
|
|
|
34
69
|
|
|
35
70
|
/**
|
|
36
71
|
* Returns true if the token is within REFRESH_BUFFER_MS of expiry
|
|
37
|
-
* and a refresh has not already been requested
|
|
72
|
+
* and a refresh has not already been requested (or the retry timer
|
|
73
|
+
* has reset the flag).
|
|
38
74
|
*/
|
|
39
75
|
needsRefresh() {
|
|
40
76
|
if (!this.token || !this.expiresAt) return false;
|
|
@@ -43,12 +79,53 @@ export class FirebaseAuth {
|
|
|
43
79
|
}
|
|
44
80
|
|
|
45
81
|
/**
|
|
46
|
-
* Mark that a refresh has been requested
|
|
47
|
-
*
|
|
82
|
+
* Mark that a refresh has been requested. Starts a backoff timer
|
|
83
|
+
* that resets the flag so needsRefresh() can fire again if the
|
|
84
|
+
* web app doesn't respond.
|
|
48
85
|
*/
|
|
49
86
|
markRefreshRequested() {
|
|
50
87
|
this._refreshRequested = true;
|
|
51
|
-
|
|
88
|
+
this._refreshAttempts++;
|
|
89
|
+
const backoff = Math.min(30_000 * Math.pow(2, this._refreshAttempts - 1), MAX_BACKOFF_MS);
|
|
90
|
+
debug(`Firebase auth refresh requested (attempt ${this._refreshAttempts}, retry in ${backoff / 1000}s)`);
|
|
91
|
+
if (this._refreshTimer) clearTimeout(this._refreshTimer);
|
|
92
|
+
this._refreshTimer = setTimeout(() => {
|
|
93
|
+
this._refreshRequested = false;
|
|
94
|
+
this._refreshTimer = null;
|
|
95
|
+
debug('Firebase auth refresh request expired — will retry on next check');
|
|
96
|
+
}, backoff);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Register a callback that fires when a fresh token arrives,
|
|
101
|
+
* receiving the array of queued webhook payloads to replay.
|
|
102
|
+
*/
|
|
103
|
+
onTokenRefresh(callback) {
|
|
104
|
+
this._replayCallback = callback;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Queue a webhook payload for replay when a fresh token arrives.
|
|
109
|
+
* Called by index.js when a webhook can't be sent due to expired auth.
|
|
110
|
+
*/
|
|
111
|
+
queueWebhook(event, data) {
|
|
112
|
+
this._pendingWebhooks.push({ event, data, queuedAt: Date.now() });
|
|
113
|
+
if (this._pendingWebhooks.length > MAX_QUEUED_WEBHOOKS) {
|
|
114
|
+
this._pendingWebhooks.shift(); // drop oldest
|
|
115
|
+
}
|
|
116
|
+
debug(`Queued webhook ${event} for replay (${this._pendingWebhooks.length} pending)`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Drain the queue and replay through the registered callback.
|
|
121
|
+
* Called automatically by setToken() when a fresh token arrives.
|
|
122
|
+
*/
|
|
123
|
+
_replayQueue() {
|
|
124
|
+
if (this._pendingWebhooks.length === 0 || !this._replayCallback) return;
|
|
125
|
+
const queue = [...this._pendingWebhooks];
|
|
126
|
+
this._pendingWebhooks = [];
|
|
127
|
+
info(`Replaying ${queue.length} queued webhook(s) with fresh token`);
|
|
128
|
+
this._replayCallback(queue);
|
|
52
129
|
}
|
|
53
130
|
|
|
54
131
|
// Full OAuth flow will be implemented in integration phase
|
package/src/agent/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/agent/index.js
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { info, warn, debug } from '../logger.js';
|
|
5
6
|
import { getConfigDir, readConfig, writeConfig, ensureConfigDir } from './config.js';
|
|
6
7
|
import { ProjectManager } from './project-manager.js';
|
|
@@ -12,6 +13,13 @@ import { CliBridge } from './cli-bridge.js';
|
|
|
12
13
|
import { AgentGit } from './git-integration.js';
|
|
13
14
|
import { FirebaseAuth } from './firebase-auth.js';
|
|
14
15
|
|
|
16
|
+
const FIREBASE_WEBHOOK_URL = 'https://webhookingest-24h6taciuq-uc.a.run.app';
|
|
17
|
+
|
|
18
|
+
// Read version from package.json so it stays in sync with npm
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf-8'));
|
|
21
|
+
const AGENT_VERSION = pkg.version;
|
|
22
|
+
|
|
15
23
|
export async function startAgent() {
|
|
16
24
|
const configDir = getConfigDir();
|
|
17
25
|
ensureConfigDir(configDir);
|
|
@@ -27,9 +35,44 @@ export async function startAgent() {
|
|
|
27
35
|
const firebaseAuth = new FirebaseAuth(configDir);
|
|
28
36
|
const webhookDispatcher = new WebhookDispatcher({
|
|
29
37
|
machine: config.machine,
|
|
30
|
-
version:
|
|
38
|
+
version: AGENT_VERSION,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Wire up webhook queue replay: when a fresh token arrives,
|
|
42
|
+
// re-dispatch any webhooks that failed due to expired auth.
|
|
43
|
+
firebaseAuth.onTokenRefresh((queue) => {
|
|
44
|
+
for (const { event, data } of queue) {
|
|
45
|
+
webhookDispatcher.dispatch(event, data, [{
|
|
46
|
+
url: FIREBASE_WEBHOOK_URL,
|
|
47
|
+
label: 'nightytidy.com',
|
|
48
|
+
headers: firebaseAuth.getAuthHeader(),
|
|
49
|
+
}]);
|
|
50
|
+
}
|
|
31
51
|
});
|
|
32
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Dispatch a webhook to user endpoints + Firestore.
|
|
55
|
+
* If not authenticated, queues the Firestore payload for replay when a fresh token arrives.
|
|
56
|
+
* User webhooks (Slack/Discord) are always sent immediately.
|
|
57
|
+
*/
|
|
58
|
+
function dispatchWithQueue(event, data, projectWebhooks) {
|
|
59
|
+
const userEndpoints = [...(projectWebhooks || [])];
|
|
60
|
+
if (firebaseAuth.isAuthenticated()) {
|
|
61
|
+
userEndpoints.push({
|
|
62
|
+
url: FIREBASE_WEBHOOK_URL,
|
|
63
|
+
label: 'nightytidy.com',
|
|
64
|
+
headers: firebaseAuth.getAuthHeader(),
|
|
65
|
+
});
|
|
66
|
+
webhookDispatcher.dispatch(event, data, userEndpoints);
|
|
67
|
+
} else {
|
|
68
|
+
if (userEndpoints.length > 0) {
|
|
69
|
+
webhookDispatcher.dispatch(event, data, userEndpoints);
|
|
70
|
+
}
|
|
71
|
+
firebaseAuth.queueWebhook(event, data);
|
|
72
|
+
warn(`Firebase webhook queued (not authenticated) — will replay when token arrives`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
33
76
|
// Track the active CLI bridge so stop-run can kill it
|
|
34
77
|
let activeBridge = null;
|
|
35
78
|
let pauseRequested = false;
|
|
@@ -267,7 +310,7 @@ export async function startAgent() {
|
|
|
267
310
|
|
|
268
311
|
case 'auth-refresh': {
|
|
269
312
|
if (msg.token && typeof msg.token === 'string') {
|
|
270
|
-
firebaseAuth.setToken(msg.token
|
|
313
|
+
firebaseAuth.setToken(msg.token);
|
|
271
314
|
info('Firebase auth token refreshed by web app');
|
|
272
315
|
reply({ type: 'auth-refresh-ack' });
|
|
273
316
|
} else {
|
|
@@ -323,16 +366,10 @@ export async function startAgent() {
|
|
|
323
366
|
}
|
|
324
367
|
runQueue.clearInterrupted();
|
|
325
368
|
// Notify Firestore
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}, [{
|
|
331
|
-
url: 'https://webhookingest-24h6taciuq-uc.a.run.app',
|
|
332
|
-
label: 'nightytidy.com',
|
|
333
|
-
headers: firebaseAuth.getAuthHeader(),
|
|
334
|
-
}]);
|
|
335
|
-
}
|
|
369
|
+
dispatchWithQueue('run_failed', {
|
|
370
|
+
projectId: interrupted.projectId,
|
|
371
|
+
run: { id: interrupted.id },
|
|
372
|
+
}, []);
|
|
336
373
|
reply({ type: 'interrupted-discarded', runId: interrupted.id });
|
|
337
374
|
break;
|
|
338
375
|
}
|
|
@@ -380,10 +417,10 @@ export async function startAgent() {
|
|
|
380
417
|
const wsServer = new AgentWebSocketServer({
|
|
381
418
|
port: config.port,
|
|
382
419
|
token: config.token,
|
|
420
|
+
version: AGENT_VERSION,
|
|
383
421
|
onCommand: handleCommand,
|
|
384
422
|
onAuthCallback: ({ token }) => {
|
|
385
|
-
|
|
386
|
-
firebaseAuth.setToken(token, Date.now() + 3600_000);
|
|
423
|
+
firebaseAuth.setToken(token);
|
|
387
424
|
info('Firebase auth token received from web app');
|
|
388
425
|
},
|
|
389
426
|
});
|
|
@@ -446,6 +483,10 @@ export async function startAgent() {
|
|
|
446
483
|
|
|
447
484
|
const project = projectManager.getProject(run.projectId);
|
|
448
485
|
if (!project) {
|
|
486
|
+
dispatchWithQueue('run_failed', {
|
|
487
|
+
projectId: run.projectId,
|
|
488
|
+
run: { id: run.id },
|
|
489
|
+
}, []);
|
|
449
490
|
runQueue.completeCurrent({ success: false });
|
|
450
491
|
processQueue();
|
|
451
492
|
return;
|
|
@@ -465,6 +506,11 @@ export async function startAgent() {
|
|
|
465
506
|
if (!initResult.success) {
|
|
466
507
|
info(` ✗ Init failed: ${initResult.stderr}`);
|
|
467
508
|
wsServer.broadcast({ type: 'run-failed', runId: run.id, error: initResult.stderr });
|
|
509
|
+
dispatchWithQueue('run_failed', {
|
|
510
|
+
project: project.name,
|
|
511
|
+
projectId: project.id,
|
|
512
|
+
run: { id: run.id },
|
|
513
|
+
}, project.webhooks);
|
|
468
514
|
runQueue.completeCurrent({ success: false });
|
|
469
515
|
processQueue();
|
|
470
516
|
return;
|
|
@@ -500,15 +546,7 @@ export async function startAgent() {
|
|
|
500
546
|
});
|
|
501
547
|
|
|
502
548
|
// Send run_started webhook so Firestore run doc is created immediately
|
|
503
|
-
|
|
504
|
-
if (firebaseAuth.isAuthenticated()) {
|
|
505
|
-
startEndpoints.push({
|
|
506
|
-
url: 'https://webhookingest-24h6taciuq-uc.a.run.app',
|
|
507
|
-
label: 'nightytidy.com',
|
|
508
|
-
headers: firebaseAuth.getAuthHeader(),
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
webhookDispatcher.dispatch('run_started', {
|
|
549
|
+
dispatchWithQueue('run_started', {
|
|
512
550
|
project: project.name,
|
|
513
551
|
projectId: project.id,
|
|
514
552
|
run: {
|
|
@@ -518,7 +556,7 @@ export async function startAgent() {
|
|
|
518
556
|
gitBranch: initResult.parsed?.runBranch || '',
|
|
519
557
|
gitTag: initResult.parsed?.tagName || '',
|
|
520
558
|
},
|
|
521
|
-
},
|
|
559
|
+
}, project.webhooks);
|
|
522
560
|
|
|
523
561
|
startHeartbeat(run.id, project.id);
|
|
524
562
|
|
|
@@ -598,20 +636,12 @@ export async function startAgent() {
|
|
|
598
636
|
});
|
|
599
637
|
|
|
600
638
|
requestTokenRefreshIfNeeded();
|
|
601
|
-
|
|
602
|
-
if (firebaseAuth.isAuthenticated()) {
|
|
603
|
-
endpoints.push({
|
|
604
|
-
url: 'https://webhookingest-24h6taciuq-uc.a.run.app',
|
|
605
|
-
label: 'nightytidy.com',
|
|
606
|
-
headers: firebaseAuth.getAuthHeader(),
|
|
607
|
-
});
|
|
608
|
-
}
|
|
609
|
-
webhookDispatcher.dispatch('step_completed', {
|
|
639
|
+
dispatchWithQueue('step_completed', {
|
|
610
640
|
project: project.name,
|
|
611
641
|
projectId: project.id,
|
|
612
642
|
step: stepData,
|
|
613
643
|
run: { id: run.id, progress: `${stepIndex + 1}/${totalSteps}`, costSoFar: stepData.cost, elapsedMs: stepData.duration },
|
|
614
|
-
},
|
|
644
|
+
}, project.webhooks);
|
|
615
645
|
stepIndex++;
|
|
616
646
|
} else {
|
|
617
647
|
const errorType = stepParsed.errorType;
|
|
@@ -654,20 +684,12 @@ export async function startAgent() {
|
|
|
654
684
|
});
|
|
655
685
|
|
|
656
686
|
requestTokenRefreshIfNeeded();
|
|
657
|
-
|
|
658
|
-
if (firebaseAuth.isAuthenticated()) {
|
|
659
|
-
endpoints.push({
|
|
660
|
-
url: 'https://webhookingest-24h6taciuq-uc.a.run.app',
|
|
661
|
-
label: 'nightytidy.com',
|
|
662
|
-
headers: firebaseAuth.getAuthHeader(),
|
|
663
|
-
});
|
|
664
|
-
}
|
|
665
|
-
webhookDispatcher.dispatch('step_failed', {
|
|
687
|
+
dispatchWithQueue('step_failed', {
|
|
666
688
|
project: project.name,
|
|
667
689
|
projectId: project.id,
|
|
668
690
|
step: { number: stepNum, name: stepParsed.name || `Step ${stepNum}`, status: 'failed', duration: stepParsed.duration || 0, cost: stepParsed.costUSD || 0 },
|
|
669
691
|
run: { id: run.id },
|
|
670
|
-
},
|
|
692
|
+
}, project.webhooks);
|
|
671
693
|
stepIndex++;
|
|
672
694
|
}
|
|
673
695
|
}
|
|
@@ -686,19 +708,11 @@ export async function startAgent() {
|
|
|
686
708
|
wsServer.broadcast({ type: 'run-completed', runId: run.id, results: {} });
|
|
687
709
|
|
|
688
710
|
requestTokenRefreshIfNeeded();
|
|
689
|
-
|
|
690
|
-
if (firebaseAuth.isAuthenticated()) {
|
|
691
|
-
completionEndpoints.push({
|
|
692
|
-
url: 'https://webhookingest-24h6taciuq-uc.a.run.app',
|
|
693
|
-
label: 'nightytidy.com',
|
|
694
|
-
headers: firebaseAuth.getAuthHeader(),
|
|
695
|
-
});
|
|
696
|
-
}
|
|
697
|
-
webhookDispatcher.dispatch('run_completed', {
|
|
711
|
+
dispatchWithQueue('run_completed', {
|
|
698
712
|
project: project.name,
|
|
699
713
|
projectId: project.id,
|
|
700
714
|
run: { id: run.id, totalSteps, completedSteps: run.steps.length, elapsedMs: Date.now() - run.startedAt },
|
|
701
|
-
},
|
|
715
|
+
}, project.webhooks);
|
|
702
716
|
|
|
703
717
|
activeBridge = null;
|
|
704
718
|
runQueue.completeCurrent({ success: true });
|
|
@@ -714,8 +728,14 @@ export async function startAgent() {
|
|
|
714
728
|
activeBridge.kill();
|
|
715
729
|
activeBridge = null;
|
|
716
730
|
}
|
|
731
|
+
const project = projectManager.getProject(current.projectId);
|
|
717
732
|
runQueue.completeCurrent({ success: false });
|
|
718
733
|
wsServer.broadcast({ type: 'run-failed', runId: msg.runId, error: 'Stopped by user' });
|
|
734
|
+
dispatchWithQueue('run_failed', {
|
|
735
|
+
project: project?.name,
|
|
736
|
+
projectId: current.projectId,
|
|
737
|
+
run: { id: msg.runId },
|
|
738
|
+
}, project?.webhooks || []);
|
|
719
739
|
reply({ type: 'run-failed', runId: msg.runId, error: 'Stopped by user' });
|
|
720
740
|
processQueue();
|
|
721
741
|
} else {
|
|
@@ -793,17 +813,11 @@ export async function startAgent() {
|
|
|
793
813
|
|
|
794
814
|
// Notify Firestore the run is active again (use run_resumed, NOT run_started
|
|
795
815
|
// which would reset completedSteps/totalCost counters to 0)
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
}, [{
|
|
802
|
-
url: 'https://webhookingest-24h6taciuq-uc.a.run.app',
|
|
803
|
-
label: 'nightytidy.com',
|
|
804
|
-
headers: firebaseAuth.getAuthHeader(),
|
|
805
|
-
}]);
|
|
806
|
-
}
|
|
816
|
+
dispatchWithQueue('run_resumed', {
|
|
817
|
+
project: project.name,
|
|
818
|
+
projectId: project.id,
|
|
819
|
+
run: { id: interrupted.id, startedAt: interrupted.startedAt },
|
|
820
|
+
}, project.webhooks);
|
|
807
821
|
|
|
808
822
|
// Run remaining steps (reuse the same step loop pattern)
|
|
809
823
|
for (const stepNum of remainingSteps) {
|
|
@@ -856,14 +870,10 @@ export async function startAgent() {
|
|
|
856
870
|
wsServer.broadcast({ type: 'step-completed', runId: interrupted.id, step: stepData, cost: stepData.cost });
|
|
857
871
|
|
|
858
872
|
requestTokenRefreshIfNeeded();
|
|
859
|
-
|
|
860
|
-
if (firebaseAuth.isAuthenticated()) {
|
|
861
|
-
endpoints.push({ url: 'https://webhookingest-24h6taciuq-uc.a.run.app', label: 'nightytidy.com', headers: firebaseAuth.getAuthHeader() });
|
|
862
|
-
}
|
|
863
|
-
webhookDispatcher.dispatch('step_completed', {
|
|
873
|
+
dispatchWithQueue('step_completed', {
|
|
864
874
|
project: project.name, projectId: project.id, step: stepData,
|
|
865
875
|
run: { id: interrupted.id, costSoFar: stepData.cost, elapsedMs: stepData.duration },
|
|
866
|
-
},
|
|
876
|
+
}, project.webhooks);
|
|
867
877
|
} else if (stepParsed.errorType === 'rate_limit') {
|
|
868
878
|
const waitMs = stepParsed.retryAfterMs || 120000;
|
|
869
879
|
info(` ⏸ Rate limited — waiting ${Math.round(waitMs / 1000)}s`);
|
|
@@ -902,12 +912,10 @@ export async function startAgent() {
|
|
|
902
912
|
wsServer.broadcast({ type: 'run-completed', runId: interrupted.id, results: {} });
|
|
903
913
|
|
|
904
914
|
requestTokenRefreshIfNeeded();
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
}, [{ url: 'https://webhookingest-24h6taciuq-uc.a.run.app', label: 'nightytidy.com', headers: firebaseAuth.getAuthHeader() }]);
|
|
910
|
-
}
|
|
915
|
+
dispatchWithQueue('run_completed', {
|
|
916
|
+
project: project.name, projectId: project.id,
|
|
917
|
+
run: { id: interrupted.id, totalSteps: interrupted.steps.length, completedSteps: runProgress.completedCount, elapsedMs: Date.now() - interrupted.startedAt },
|
|
918
|
+
}, project.webhooks);
|
|
911
919
|
|
|
912
920
|
activeBridge = null;
|
|
913
921
|
runQueue.completeCurrent({ success: true });
|
|
@@ -937,12 +945,10 @@ export async function startAgent() {
|
|
|
937
945
|
wsServer.broadcast({ type: 'run-completed', runId: interrupted.id, status: 'completed', results: {} });
|
|
938
946
|
|
|
939
947
|
requestTokenRefreshIfNeeded();
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
}, [{ url: 'https://webhookingest-24h6taciuq-uc.a.run.app', label: 'nightytidy.com', headers: firebaseAuth.getAuthHeader() }]);
|
|
945
|
-
}
|
|
948
|
+
dispatchWithQueue('run_completed', {
|
|
949
|
+
project: project.name, projectId: project.id,
|
|
950
|
+
run: { id: interrupted.id, totalSteps: interrupted.steps.length, completedSteps: interrupted.lastProgress?.completedCount || 0, elapsedMs: Date.now() - interrupted.startedAt },
|
|
951
|
+
}, project.webhooks);
|
|
946
952
|
|
|
947
953
|
activeBridge = null;
|
|
948
954
|
runQueue.completeCurrent({ success: true });
|
|
@@ -960,15 +966,14 @@ export async function startAgent() {
|
|
|
960
966
|
currentProjectId = projectId;
|
|
961
967
|
heartbeatInterval = setInterval(() => {
|
|
962
968
|
if (!firebaseAuth.isAuthenticated()) return;
|
|
963
|
-
const endpoints = [{
|
|
964
|
-
url: 'https://webhookingest-24h6taciuq-uc.a.run.app',
|
|
965
|
-
label: 'nightytidy.com',
|
|
966
|
-
headers: firebaseAuth.getAuthHeader(),
|
|
967
|
-
}];
|
|
968
969
|
webhookDispatcher.dispatch('heartbeat', {
|
|
969
970
|
projectId: currentProjectId,
|
|
970
971
|
run: { id: currentRunId },
|
|
971
|
-
},
|
|
972
|
+
}, [{
|
|
973
|
+
url: FIREBASE_WEBHOOK_URL,
|
|
974
|
+
label: 'nightytidy.com',
|
|
975
|
+
headers: firebaseAuth.getAuthHeader(),
|
|
976
|
+
}]);
|
|
972
977
|
}, 60_000);
|
|
973
978
|
}
|
|
974
979
|
|
|
@@ -990,22 +995,15 @@ export async function startAgent() {
|
|
|
990
995
|
runQueue.markInterrupted(runProgress);
|
|
991
996
|
|
|
992
997
|
// Best-effort: notify Firestore (may not complete before exit)
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
id: current.id,
|
|
1003
|
-
completedSteps: runProgress.completedCount,
|
|
1004
|
-
failedSteps: runProgress.failedCount,
|
|
1005
|
-
totalCost: runProgress.totalCost,
|
|
1006
|
-
},
|
|
1007
|
-
}, endpoints);
|
|
1008
|
-
}
|
|
998
|
+
dispatchWithQueue('run_interrupted', {
|
|
999
|
+
projectId: current.projectId,
|
|
1000
|
+
run: {
|
|
1001
|
+
id: current.id,
|
|
1002
|
+
completedSteps: runProgress.completedCount,
|
|
1003
|
+
failedSteps: runProgress.failedCount,
|
|
1004
|
+
totalCost: runProgress.totalCost,
|
|
1005
|
+
},
|
|
1006
|
+
}, []);
|
|
1009
1007
|
|
|
1010
1008
|
// Notify connected clients
|
|
1011
1009
|
wsServer.broadcast({
|
|
@@ -1058,8 +1056,8 @@ export async function startAgent() {
|
|
|
1058
1056
|
|
|
1059
1057
|
// Best-effort: notify Firestore that this run is interrupted
|
|
1060
1058
|
// (in case the shutdown webhook didn't make it)
|
|
1061
|
-
if (
|
|
1062
|
-
|
|
1059
|
+
if (proj) {
|
|
1060
|
+
dispatchWithQueue('run_interrupted', {
|
|
1063
1061
|
projectId: proj.id,
|
|
1064
1062
|
run: {
|
|
1065
1063
|
id: interrupted.id,
|
|
@@ -1067,11 +1065,7 @@ export async function startAgent() {
|
|
|
1067
1065
|
failedSteps: progress.failedCount || 0,
|
|
1068
1066
|
totalCost: progress.totalCost || 0,
|
|
1069
1067
|
},
|
|
1070
|
-
}, [
|
|
1071
|
-
url: 'https://webhookingest-24h6taciuq-uc.a.run.app',
|
|
1072
|
-
label: 'nightytidy.com',
|
|
1073
|
-
headers: firebaseAuth.getAuthHeader(),
|
|
1074
|
-
}]);
|
|
1068
|
+
}, []);
|
|
1075
1069
|
}
|
|
1076
1070
|
}
|
|
1077
1071
|
|
|
@@ -1084,7 +1078,7 @@ export async function startAgent() {
|
|
|
1084
1078
|
}
|
|
1085
1079
|
|
|
1086
1080
|
// Print startup info
|
|
1087
|
-
console.log(`\nNightyTidy Agent
|
|
1081
|
+
console.log(`\nNightyTidy Agent v${AGENT_VERSION}`);
|
|
1088
1082
|
console.log(`WebSocket: ws://127.0.0.1:${actualPort}`);
|
|
1089
1083
|
console.log(`Token: ${config.token.slice(0, 6)}...(see ~/.nightytidy/config.json)`);
|
|
1090
1084
|
if (interrupted) {
|
|
@@ -6,8 +6,9 @@ import { info, debug, warn } from '../logger.js';
|
|
|
6
6
|
const RATE_LIMIT_PER_SEC = 10;
|
|
7
7
|
|
|
8
8
|
export class AgentWebSocketServer {
|
|
9
|
-
constructor({ port, token, onCommand, onAuthCallback }) {
|
|
9
|
+
constructor({ port, token, onCommand, onAuthCallback, version }) {
|
|
10
10
|
this.port = port;
|
|
11
|
+
this.version = version || '0.0.0';
|
|
11
12
|
this.token = token;
|
|
12
13
|
this.onCommand = onCommand || (() => {});
|
|
13
14
|
this.onAuthCallback = onAuthCallback || (() => {});
|
|
@@ -130,7 +131,7 @@ export class AgentWebSocketServer {
|
|
|
130
131
|
ws.send(JSON.stringify({
|
|
131
132
|
type: 'connected',
|
|
132
133
|
machine: process.env.COMPUTERNAME || os.hostname(),
|
|
133
|
-
version:
|
|
134
|
+
version: this.version,
|
|
134
135
|
startedAt: this.startedAt,
|
|
135
136
|
}));
|
|
136
137
|
debug('Client authenticated');
|