git-reviewer 2.15.0 → 2.16.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 +66 -5
- package/bin/reviewer.js +256 -1
- package/lib/agent.js +12 -2
- package/lib/answerer.js +191 -0
- package/lib/channel.js +286 -0
- package/lib/claude-background.js +316 -0
- package/lib/claude-command.js +90 -0
- package/lib/claude-sessions.js +229 -0
- package/lib/cli.js +147 -7
- package/lib/comments.js +175 -10
- package/lib/presence.js +223 -0
- package/lib/store.js +284 -9
- package/lib/threads.js +180 -0
- package/lib/watch.js +131 -0
- package/man/git-reviewer.1 +47 -0
- package/package.json +1 -1
- package/public/app.js +739 -107
- package/public/index.html +33 -0
- package/public/style.css +271 -0
- package/server.js +307 -34
package/lib/comments.js
CHANGED
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* The stored shape of a review comment.
|
|
5
7
|
*
|
|
6
|
-
* The client keeps extra bookkeeping on each comment (
|
|
7
|
-
* draft text). Only the fields below are written to disk, so a comment
|
|
8
|
-
* stays readable and stable across UI changes.
|
|
8
|
+
* The client keeps extra bookkeeping on each comment (diff positions, match
|
|
9
|
+
* state, draft text). Only the fields below are written to disk, so a comment
|
|
10
|
+
* file stays readable and stable across UI changes.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* @typedef {object} FollowUp
|
|
15
|
+
* @property {string} [key] stable identity, minted when it is written
|
|
13
16
|
* @property {string} text
|
|
14
17
|
* @property {string} [timestamp] ISO 8601
|
|
18
|
+
* @property {string} [by] who wrote it, when it was not the reviewer: an
|
|
19
|
+
* agent answering in the thread, say `claude`. Absent means the reviewer,
|
|
20
|
+
* which is what every follow-up written before this field existed was.
|
|
21
|
+
* @property {string} [session] the agent session that wrote it, when known
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* @typedef {object} Comment
|
|
26
|
+
* @property {string} [key] stable identity, minted when the comment is created
|
|
19
27
|
* @property {string} file repo-relative path
|
|
20
28
|
* @property {number} line 1-based line number
|
|
21
29
|
* @property {string} [lineContent] the line the comment is anchored to
|
|
@@ -24,6 +32,136 @@
|
|
|
24
32
|
* @property {FollowUp[]} [followUps]
|
|
25
33
|
*/
|
|
26
34
|
|
|
35
|
+
/**
|
|
36
|
+
* What a key looks like: short, and safe in a URL, a filename and an
|
|
37
|
+
* unquoted shell argument, because `reviewer reply <key>` is typed.
|
|
38
|
+
*/
|
|
39
|
+
const KEY_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
40
|
+
|
|
41
|
+
/** A name in `by`: something a person would recognise in a thread. */
|
|
42
|
+
const AUTHOR_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,39}$/;
|
|
43
|
+
|
|
44
|
+
/** A session reference in `session`: an id, not prose. */
|
|
45
|
+
const SESSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
46
|
+
|
|
47
|
+
/** @param {unknown} value */
|
|
48
|
+
function isKey(value) {
|
|
49
|
+
return typeof value === 'string' && KEY_PATTERN.test(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A new random key: eight characters, 48 bits from `crypto`.
|
|
54
|
+
*
|
|
55
|
+
* Short on purpose. The comment file is meant to stay readable, the key is
|
|
56
|
+
* typed on a command line, and two collisions in one review would need tens
|
|
57
|
+
* of millions of comments.
|
|
58
|
+
*
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
function mintKey() {
|
|
62
|
+
return crypto.randomBytes(6).toString('base64url');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The key a comment written without one has always had.
|
|
67
|
+
*
|
|
68
|
+
* Derived rather than random so that every reader -- a tab, an agent, `reviewer
|
|
69
|
+
* export` -- names the same comment the same way without anyone having to
|
|
70
|
+
* write the file first. The parts are what identified a comment before keys
|
|
71
|
+
* existed, plus how many earlier entries shared them, so two identical
|
|
72
|
+
* comments on one line still get two keys.
|
|
73
|
+
*
|
|
74
|
+
* @param {string[]} parts
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
function derivedKey(parts) {
|
|
78
|
+
return crypto.createHash('sha256').update(parts.join('\u0000')).digest('base64url').slice(0, 8);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Give every comment and follow-up a key, keeping the ones they already have.
|
|
83
|
+
*
|
|
84
|
+
* Comments saved before keys existed are read through here, and so is
|
|
85
|
+
* anything posted by a client that does not send keys. Both get the derived
|
|
86
|
+
* key, so the same comment comes back with the same key on every read, and a
|
|
87
|
+
* script that re-posts the same list keeps its comments' identities rather
|
|
88
|
+
* than turning each save into a delete and an add.
|
|
89
|
+
*
|
|
90
|
+
* @template {Comment} T
|
|
91
|
+
* @param {T[]} comments
|
|
92
|
+
* @returns {T[]} new objects; the input is left alone
|
|
93
|
+
*/
|
|
94
|
+
function withKeys(comments) {
|
|
95
|
+
const occurrences = new Map();
|
|
96
|
+
const nth = identity => {
|
|
97
|
+
const n = occurrences.get(identity) ?? 0;
|
|
98
|
+
occurrences.set(identity, n + 1);
|
|
99
|
+
return String(n);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return comments.map(comment => {
|
|
103
|
+
const key = isKey(comment.key)
|
|
104
|
+
? comment.key
|
|
105
|
+
: derivedKey(['comment', comment.file, String(comment.line), comment.text,
|
|
106
|
+
nth(JSON.stringify([comment.file, comment.line, comment.text]))]);
|
|
107
|
+
|
|
108
|
+
const keyed = { ...comment, key };
|
|
109
|
+
if (Array.isArray(comment.followUps)) {
|
|
110
|
+
keyed.followUps = comment.followUps.map(followUp => (isKey(followUp?.key)
|
|
111
|
+
? followUp
|
|
112
|
+
: {
|
|
113
|
+
...followUp,
|
|
114
|
+
key: derivedKey(['follow-up', key, followUp?.timestamp ?? '', followUp?.text ?? '',
|
|
115
|
+
followUp?.by ?? '', nth(JSON.stringify([key, followUp?.timestamp, followUp?.text, followUp?.by]))])
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
return keyed;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reduce a follow-up to the fields that get persisted.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} followUp
|
|
126
|
+
* @param {string} where position, for the error message
|
|
127
|
+
* @returns {FollowUp}
|
|
128
|
+
* @throws {TypeError} when it is malformed
|
|
129
|
+
*/
|
|
130
|
+
function normalizeFollowUp(followUp, where) {
|
|
131
|
+
if (followUp === null || typeof followUp !== 'object' || Array.isArray(followUp)) {
|
|
132
|
+
throw new TypeError(`${where} must be an object`);
|
|
133
|
+
}
|
|
134
|
+
if (typeof followUp.text !== 'string' || followUp.text.trim() === '') {
|
|
135
|
+
throw new TypeError(`${where}.text must be a non-empty string`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @type {FollowUp} */
|
|
139
|
+
const entry = {};
|
|
140
|
+
if (followUp.key !== undefined) {
|
|
141
|
+
if (!isKey(followUp.key)) throw new TypeError(`${where}.key must be 1-64 letters, digits, _ or -`);
|
|
142
|
+
entry.key = followUp.key;
|
|
143
|
+
}
|
|
144
|
+
entry.text = followUp.text;
|
|
145
|
+
if (followUp.timestamp) entry.timestamp = followUp.timestamp;
|
|
146
|
+
|
|
147
|
+
// Empty and absent are the same thing here -- the reviewer -- so only a
|
|
148
|
+
// real name is stored, and the file never says `"by": ""`.
|
|
149
|
+
if (followUp.by !== undefined && followUp.by !== null && followUp.by !== '') {
|
|
150
|
+
if (typeof followUp.by !== 'string' || !AUTHOR_PATTERN.test(followUp.by)) {
|
|
151
|
+
throw new TypeError(`${where}.by must be a short name, such as "claude"`);
|
|
152
|
+
}
|
|
153
|
+
entry.by = followUp.by;
|
|
154
|
+
}
|
|
155
|
+
if (followUp.session !== undefined && followUp.session !== null && followUp.session !== '') {
|
|
156
|
+
if (typeof followUp.session !== 'string' || !SESSION_PATTERN.test(followUp.session)) {
|
|
157
|
+
throw new TypeError(`${where}.session must be a session id`);
|
|
158
|
+
}
|
|
159
|
+
entry.session = followUp.session;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return entry;
|
|
163
|
+
}
|
|
164
|
+
|
|
27
165
|
/**
|
|
28
166
|
* Reduce a client comment to the fields that get persisted.
|
|
29
167
|
*
|
|
@@ -54,8 +192,12 @@ function normalizeComment(comment, index) {
|
|
|
54
192
|
if (typeof comment.text !== 'string' || comment.text.trim() === '') {
|
|
55
193
|
throw new TypeError(`${where}.text must be a non-empty string`);
|
|
56
194
|
}
|
|
195
|
+
if (comment.key !== undefined && !isKey(comment.key)) {
|
|
196
|
+
throw new TypeError(`${where}.key must be 1-64 letters, digits, _ or -`);
|
|
197
|
+
}
|
|
57
198
|
|
|
58
199
|
const normalized = {
|
|
200
|
+
...(comment.key !== undefined ? { key: comment.key } : {}),
|
|
59
201
|
file: comment.file,
|
|
60
202
|
line: comment.line,
|
|
61
203
|
lineContent: comment.lineContent,
|
|
@@ -67,11 +209,9 @@ function normalizeComment(comment, index) {
|
|
|
67
209
|
}
|
|
68
210
|
|
|
69
211
|
if (Array.isArray(comment.followUps) && comment.followUps.length > 0) {
|
|
70
|
-
normalized.followUps = comment.followUps.map(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return entry;
|
|
74
|
-
});
|
|
212
|
+
normalized.followUps = comment.followUps.map(
|
|
213
|
+
(followUp, i) => normalizeFollowUp(followUp, `${where}.followUps[${i}]`)
|
|
214
|
+
);
|
|
75
215
|
}
|
|
76
216
|
|
|
77
217
|
return normalized;
|
|
@@ -80,6 +220,10 @@ function normalizeComment(comment, index) {
|
|
|
80
220
|
/**
|
|
81
221
|
* Normalize a batch of comments.
|
|
82
222
|
*
|
|
223
|
+
* Two comments claiming one key are refused: a key is how a reply finds its
|
|
224
|
+
* thread, and a reply that could land on either of two comments is the
|
|
225
|
+
* confidently-wrong outcome keys exist to rule out.
|
|
226
|
+
*
|
|
83
227
|
* @param {object[]} comments
|
|
84
228
|
* @returns {Comment[]}
|
|
85
229
|
* @throws {TypeError} when `comments` is not an array or a comment is malformed
|
|
@@ -88,7 +232,18 @@ function normalizeComments(comments) {
|
|
|
88
232
|
if (!Array.isArray(comments)) {
|
|
89
233
|
throw new TypeError('comments must be an array');
|
|
90
234
|
}
|
|
91
|
-
|
|
235
|
+
const normalized = comments.map((comment, index) => normalizeComment(comment, index));
|
|
236
|
+
|
|
237
|
+
const seen = new Set();
|
|
238
|
+
normalized.forEach((comment, index) => {
|
|
239
|
+
if (comment.key === undefined) return;
|
|
240
|
+
if (seen.has(comment.key)) {
|
|
241
|
+
throw new TypeError(`comments[${index}].key "${comment.key}" is used by another comment`);
|
|
242
|
+
}
|
|
243
|
+
seen.add(comment.key);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return normalized;
|
|
92
247
|
}
|
|
93
248
|
|
|
94
249
|
/**
|
|
@@ -116,4 +271,14 @@ function groupByFile(comments) {
|
|
|
116
271
|
]);
|
|
117
272
|
}
|
|
118
273
|
|
|
119
|
-
module.exports = {
|
|
274
|
+
module.exports = {
|
|
275
|
+
normalizeComment,
|
|
276
|
+
normalizeComments,
|
|
277
|
+
normalizeFollowUp,
|
|
278
|
+
groupByFile,
|
|
279
|
+
withKeys,
|
|
280
|
+
mintKey,
|
|
281
|
+
isKey,
|
|
282
|
+
KEY_PATTERN,
|
|
283
|
+
AUTHOR_PATTERN
|
|
284
|
+
};
|
package/lib/presence.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const fsp = require('fs').promises;
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
|
|
8
|
+
const { repoKey } = require('./review');
|
|
9
|
+
const { writeFileAtomic, isAlive } = require('./store');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Which agents are connected to a review right now.
|
|
13
|
+
*
|
|
14
|
+
* An agent answering in a review's threads may live in another process -- a
|
|
15
|
+
* Claude Code session running `reviewer channel` -- or inside the server
|
|
16
|
+
* itself. Either way it leaves a small file here saying who it is and what it
|
|
17
|
+
* is working on, refreshes it while it lives, and removes it when it stops.
|
|
18
|
+
* The page shows those files as "Claude is connected" and "answering…", which
|
|
19
|
+
* is the difference between a reply that is coming and silence.
|
|
20
|
+
*
|
|
21
|
+
* A file is trusted only while its process is alive and its heartbeat is
|
|
22
|
+
* recent, so an agent killed without the chance to clean up drops out of the
|
|
23
|
+
* list on its own instead of claiming to be connected forever.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const HEARTBEAT_MS = 10000;
|
|
27
|
+
const STALE_MS = 30000;
|
|
28
|
+
const POLL_MS = 3000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} AgentPresence
|
|
32
|
+
* @property {string} id
|
|
33
|
+
* @property {string} repoPath
|
|
34
|
+
* @property {'channel'|'background'|'watch'} kind how it is connected
|
|
35
|
+
* @property {string} agent who answers, e.g. `claude`
|
|
36
|
+
* @property {string|null} session the agent's own session id, when known
|
|
37
|
+
* @property {string|null} title what that session is about, when known
|
|
38
|
+
* @property {number} pid
|
|
39
|
+
* @property {string} startedAt
|
|
40
|
+
* @property {string} heartbeatAt
|
|
41
|
+
* @property {string[]} working keys of threads it has been asked and not yet answered
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** @param {string} reviewsDir @param {string} repoPath */
|
|
45
|
+
function agentsDir(reviewsDir, repoPath) {
|
|
46
|
+
return path.join(reviewsDir, 'agents', repoKey(repoPath));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Announce an agent and keep the announcement fresh until `close()`.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} reviewsDir
|
|
53
|
+
* @param {string} repoPath
|
|
54
|
+
* @param {Omit<AgentPresence, 'id'|'repoPath'|'pid'|'startedAt'|'heartbeatAt'|'working'> & {working?: string[]}} info
|
|
55
|
+
*/
|
|
56
|
+
function announce(reviewsDir, repoPath, info) {
|
|
57
|
+
const id = crypto.randomBytes(6).toString('base64url');
|
|
58
|
+
const dir = agentsDir(reviewsDir, repoPath);
|
|
59
|
+
const file = path.join(dir, `${id}.json`);
|
|
60
|
+
const startedAt = new Date().toISOString();
|
|
61
|
+
/** @type {AgentPresence} */
|
|
62
|
+
let record = {
|
|
63
|
+
id,
|
|
64
|
+
repoPath,
|
|
65
|
+
kind: info.kind,
|
|
66
|
+
agent: info.agent,
|
|
67
|
+
session: info.session ?? null,
|
|
68
|
+
title: info.title ?? null,
|
|
69
|
+
pid: process.pid,
|
|
70
|
+
startedAt,
|
|
71
|
+
heartbeatAt: startedAt,
|
|
72
|
+
working: info.working ?? []
|
|
73
|
+
};
|
|
74
|
+
let closed = false;
|
|
75
|
+
let writing = Promise.resolve();
|
|
76
|
+
|
|
77
|
+
const write = () => {
|
|
78
|
+
record = { ...record, heartbeatAt: new Date().toISOString() };
|
|
79
|
+
const snapshot = `${JSON.stringify(record, null, 2)}\n`;
|
|
80
|
+
writing = writing
|
|
81
|
+
.then(async () => {
|
|
82
|
+
if (closed) return;
|
|
83
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
84
|
+
await writeFileAtomic(file, snapshot);
|
|
85
|
+
})
|
|
86
|
+
.catch(() => {});
|
|
87
|
+
return writing;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const heartbeat = setInterval(write, HEARTBEAT_MS);
|
|
91
|
+
heartbeat.unref();
|
|
92
|
+
|
|
93
|
+
// A synchronous last word, for an exit that gives no time for anything else.
|
|
94
|
+
const removeNow = () => {
|
|
95
|
+
try {
|
|
96
|
+
fs.rmSync(file, { force: true });
|
|
97
|
+
} catch {
|
|
98
|
+
/* nothing more can be done on the way out */
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
process.on('exit', removeNow);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
id,
|
|
105
|
+
ready: write(),
|
|
106
|
+
/** @param {Partial<AgentPresence>} fields */
|
|
107
|
+
update(fields) {
|
|
108
|
+
record = { ...record, ...fields };
|
|
109
|
+
return write();
|
|
110
|
+
},
|
|
111
|
+
get record() {
|
|
112
|
+
return record;
|
|
113
|
+
},
|
|
114
|
+
async close() {
|
|
115
|
+
closed = true;
|
|
116
|
+
clearInterval(heartbeat);
|
|
117
|
+
process.off('exit', removeNow);
|
|
118
|
+
await writing;
|
|
119
|
+
await fsp.rm(file, { force: true });
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The agents connected to a review, live ones only.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} reviewsDir
|
|
128
|
+
* @param {string} repoPath
|
|
129
|
+
* @param {object} [options]
|
|
130
|
+
* @param {number} [options.now]
|
|
131
|
+
* @returns {Promise<AgentPresence[]>} oldest first
|
|
132
|
+
*/
|
|
133
|
+
async function listAgents(reviewsDir, repoPath, { now = Date.now() } = {}) {
|
|
134
|
+
const dir = agentsDir(reviewsDir, repoPath);
|
|
135
|
+
let names;
|
|
136
|
+
try {
|
|
137
|
+
names = await fsp.readdir(dir);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error.code === 'ENOENT') return [];
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const agents = [];
|
|
144
|
+
for (const name of names) {
|
|
145
|
+
if (!name.endsWith('.json')) continue;
|
|
146
|
+
const file = path.join(dir, name);
|
|
147
|
+
let record;
|
|
148
|
+
try {
|
|
149
|
+
record = JSON.parse(await fsp.readFile(file, 'utf-8'));
|
|
150
|
+
} catch {
|
|
151
|
+
continue; // being replaced right now, or not ours
|
|
152
|
+
}
|
|
153
|
+
const fresh = now - Date.parse(record.heartbeatAt) < STALE_MS;
|
|
154
|
+
if (fresh && isAlive(record.pid)) {
|
|
155
|
+
agents.push(record);
|
|
156
|
+
} else if (!isAlive(record.pid)) {
|
|
157
|
+
// Its process is gone and cannot clean up after itself any more.
|
|
158
|
+
await fsp.rm(file, { force: true }).catch(() => {});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return agents.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Hear about agents connecting, disconnecting and changing what they work on.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} reviewsDir
|
|
169
|
+
* @param {string} repoPath
|
|
170
|
+
* @param {(agents: AgentPresence[]) => void} onChange called at the start and on every change
|
|
171
|
+
* @param {object} [options]
|
|
172
|
+
* @param {number} [options.pollMs]
|
|
173
|
+
* @returns {{close: () => void, ready: Promise<void>}}
|
|
174
|
+
*/
|
|
175
|
+
function watchAgents(reviewsDir, repoPath, onChange, { pollMs = POLL_MS } = {}) {
|
|
176
|
+
const dir = agentsDir(reviewsDir, repoPath);
|
|
177
|
+
let closed = false;
|
|
178
|
+
let last;
|
|
179
|
+
let timer = null;
|
|
180
|
+
|
|
181
|
+
const check = async () => {
|
|
182
|
+
if (closed) return;
|
|
183
|
+
const agents = await listAgents(reviewsDir, repoPath).catch(() => []);
|
|
184
|
+
// Heartbeats move every ten seconds without anything a person would see
|
|
185
|
+
// changing, so they are left out of the comparison.
|
|
186
|
+
const signature = JSON.stringify(agents.map(({ heartbeatAt, ...agent }) => agent));
|
|
187
|
+
if (!closed && signature !== last) {
|
|
188
|
+
last = signature;
|
|
189
|
+
onChange(agents);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
let watcher = null;
|
|
194
|
+
try {
|
|
195
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
196
|
+
watcher = fs.watch(dir, () => {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
timer = setTimeout(check, 25);
|
|
199
|
+
});
|
|
200
|
+
watcher.on('error', () => {
|
|
201
|
+
watcher?.close();
|
|
202
|
+
watcher = null;
|
|
203
|
+
});
|
|
204
|
+
} catch {
|
|
205
|
+
watcher = null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Also how an agent that died without cleaning up is noticed.
|
|
209
|
+
const poll = setInterval(check, pollMs);
|
|
210
|
+
poll.unref();
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
ready: check(),
|
|
214
|
+
close() {
|
|
215
|
+
closed = true;
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
clearInterval(poll);
|
|
218
|
+
watcher?.close();
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = { announce, listAgents, watchAgents, agentsDir, isAlive, HEARTBEAT_MS, STALE_MS };
|