git-reviewer 2.14.1 → 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 +122 -4
- package/bin/reviewer.js +286 -7
- package/lib/agent.js +25 -5
- package/lib/answerer.js +191 -0
- package/lib/changes.js +83 -4
- 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 +197 -20
- package/lib/comments.js +175 -10
- package/lib/export.js +2 -2
- package/lib/presence.js +223 -0
- package/lib/review.js +3 -1
- package/lib/sessions.js +8 -4
- package/lib/store.js +290 -7
- package/lib/threads.js +180 -0
- package/lib/watch.js +131 -0
- package/man/git-reviewer.1 +58 -0
- package/package.json +1 -1
- package/public/app.js +811 -119
- package/public/index.html +33 -0
- package/public/style.css +277 -0
- package/server.js +338 -43
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 };
|
package/lib/review.js
CHANGED
|
@@ -124,13 +124,15 @@ function reviewFilename(repoPath, generatedAt) {
|
|
|
124
124
|
* @param {string} args.repoPath repository the review covers
|
|
125
125
|
* @param {import('./comments').Comment[]} args.comments
|
|
126
126
|
* @param {Date} args.generatedAt
|
|
127
|
+
* @param {string|null} [args.scope] an explanation of an explicitly restricted comparison
|
|
127
128
|
* @returns {string}
|
|
128
129
|
*/
|
|
129
|
-
function formatReview({ repoPath, comments, generatedAt }) {
|
|
130
|
+
function formatReview({ repoPath, comments, generatedAt, scope = null }) {
|
|
130
131
|
const sections = [
|
|
131
132
|
'# Code Review\n',
|
|
132
133
|
`Repository: ${repoPath}`,
|
|
133
134
|
`Generated: ${generatedAt.toISOString()}`,
|
|
135
|
+
...(scope ? [scope] : []),
|
|
134
136
|
`Total Comments: ${comments.length}\n`
|
|
135
137
|
];
|
|
136
138
|
|
package/lib/sessions.js
CHANGED
|
@@ -35,7 +35,7 @@ class SessionStore {
|
|
|
35
35
|
*/
|
|
36
36
|
constructor(generateId = () => crypto.randomBytes(8).toString('hex')) {
|
|
37
37
|
this.generateId = generateId;
|
|
38
|
-
/** @type {Map<string, {repoPath: string, mode: ReviewMode, range: ReviewRange|null}>} */
|
|
38
|
+
/** @type {Map<string, {repoPath: string, mode: ReviewMode, range: ReviewRange|null, untracked?: boolean}>} */
|
|
39
39
|
this.sessions = new Map();
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -43,17 +43,21 @@ class SessionStore {
|
|
|
43
43
|
* @param {string} repoPath absolute path of the opened repository
|
|
44
44
|
* @param {ReviewMode} mode
|
|
45
45
|
* @param {ReviewRange|null} [range] the two refs, for a commit-based review
|
|
46
|
+
* @param {boolean} [untracked] whether a working comparison includes untracked files
|
|
46
47
|
* @returns {string} the new session id
|
|
47
48
|
*/
|
|
48
|
-
create(repoPath, mode, range = null) {
|
|
49
|
+
create(repoPath, mode, range = null, untracked = true) {
|
|
49
50
|
const repoId = this.generateId();
|
|
50
|
-
this.sessions.set(repoId, {
|
|
51
|
+
this.sessions.set(repoId, {
|
|
52
|
+
repoPath, mode, range,
|
|
53
|
+
...(mode === 'working' && untracked === false ? { untracked: false } : {})
|
|
54
|
+
});
|
|
51
55
|
return repoId;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
/**
|
|
55
59
|
* @param {string} repoId
|
|
56
|
-
* @returns {{repoPath: string, mode: ReviewMode, range: ReviewRange|null}|undefined}
|
|
60
|
+
* @returns {{repoPath: string, mode: ReviewMode, range: ReviewRange|null, untracked?: boolean}|undefined}
|
|
57
61
|
*/
|
|
58
62
|
get(repoId) {
|
|
59
63
|
return this.sessions.get(repoId);
|
package/lib/store.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs').promises;
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
5
6
|
|
|
6
7
|
const { commentsFilename, legacyCommentsFilename, canonicalRepoPath } = require('./review');
|
|
8
|
+
const { withKeys } = require('./comments');
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Reading a repository's saved comments, whatever name they were written under.
|
|
@@ -74,10 +76,15 @@ async function readJson(file) {
|
|
|
74
76
|
*
|
|
75
77
|
* @param {string} reviewsDir
|
|
76
78
|
* @param {string} repoPath
|
|
77
|
-
* @returns {Promise<{comments: object[], mode: string|null, range: object|null, adopted: boolean}>}
|
|
79
|
+
* @returns {Promise<{comments: object[], mode: string|null, range: object|null, untracked?: boolean, adopted: boolean, lastUpdated: string|null}>}
|
|
78
80
|
* `mode` is null for a file written before it was recorded -- there is
|
|
79
81
|
* nothing on disk that says what was compared, and guessing would be
|
|
80
|
-
* worse than admitting it.
|
|
82
|
+
* worse than admitting it. `lastUpdated` is null when the file carries
|
|
83
|
+
* no stamp (or there is no file yet): it is the version token a caller
|
|
84
|
+
* sends back on save so the server can tell a current writer from a
|
|
85
|
+
* stale one (#107). Every comment and follow-up comes back with a `key`;
|
|
86
|
+
* one saved before keys existed gets the key derived from its content (see
|
|
87
|
+
* `withKeys`), the same on every read.
|
|
81
88
|
* @throws {ReviewOwnershipError} when the current file names another repository
|
|
82
89
|
*/
|
|
83
90
|
async function readSavedComments(reviewsDir, repoPath) {
|
|
@@ -90,15 +97,20 @@ async function readSavedComments(reviewsDir, repoPath) {
|
|
|
90
97
|
if (!belongsTo(saved.repoPath, repoPath)) {
|
|
91
98
|
throw new ReviewOwnershipError(repoPath, saved.repoPath);
|
|
92
99
|
}
|
|
93
|
-
return {
|
|
100
|
+
return {
|
|
101
|
+
comments: withKeys(saved.comments ?? []), mode: saved.mode ?? null, range: saved.range ?? null,
|
|
102
|
+
...(saved.mode === 'working' && saved.untracked === false ? { untracked: false } : {}),
|
|
103
|
+
adopted: false,
|
|
104
|
+
lastUpdated: saved.lastUpdated ?? null
|
|
105
|
+
};
|
|
94
106
|
}
|
|
95
107
|
|
|
96
108
|
const legacyName = legacyCommentsFilename(repoPath);
|
|
97
|
-
if (legacyName === commentsFilename(repoPath)) return { comments: [], mode: null, range: null, adopted: false };
|
|
109
|
+
if (legacyName === commentsFilename(repoPath)) return { comments: [], mode: null, range: null, adopted: false, lastUpdated: null };
|
|
98
110
|
|
|
99
111
|
const legacy = await readJson(path.join(reviewsDir, legacyName));
|
|
100
112
|
if (legacy === null || !belongsTo(legacy.repoPath, repoPath)) {
|
|
101
|
-
return { comments: [], mode: null, range: null, adopted: false };
|
|
113
|
+
return { comments: [], mode: null, range: null, adopted: false, lastUpdated: null };
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
// Move it under the new name so the next read is a plain hit and the old
|
|
@@ -110,7 +122,278 @@ async function readSavedComments(reviewsDir, repoPath) {
|
|
|
110
122
|
/* the comments are already in hand; renaming is housekeeping */
|
|
111
123
|
}
|
|
112
124
|
|
|
113
|
-
return {
|
|
125
|
+
return {
|
|
126
|
+
comments: withKeys(legacy.comments ?? []), mode: legacy.mode ?? null, range: legacy.range ?? null,
|
|
127
|
+
...(legacy.mode === 'working' && legacy.untracked === false ? { untracked: false } : {}),
|
|
128
|
+
adopted: true,
|
|
129
|
+
lastUpdated: legacy.lastUpdated ?? null
|
|
130
|
+
};
|
|
114
131
|
}
|
|
115
132
|
|
|
116
|
-
|
|
133
|
+
/**
|
|
134
|
+
* The `lastUpdated` stamp for a save that follows `previous`.
|
|
135
|
+
*
|
|
136
|
+
* Stamps are the version token for conditional saves (#107), so a new stamp
|
|
137
|
+
* must differ from the one it replaces even when two saves land inside the
|
|
138
|
+
* same millisecond -- otherwise a stale tab would look current by accident.
|
|
139
|
+
*
|
|
140
|
+
* @param {string|null} previous stamp currently on disk, if any
|
|
141
|
+
* @returns {string} ISO 8601 stamp strictly after `previous`
|
|
142
|
+
*/
|
|
143
|
+
function nextLastUpdated(previous) {
|
|
144
|
+
const parsed = previous ? Date.parse(previous) : NaN;
|
|
145
|
+
const stamp = Number.isNaN(parsed) ? Date.now() : Math.max(Date.now(), parsed + 1);
|
|
146
|
+
return new Date(stamp).toISOString();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// A lock older than this belongs to a writer that died holding it. Reading
|
|
150
|
+
// and writing one small JSON file takes milliseconds; ten seconds is not a
|
|
151
|
+
// writer that is merely slow. A lock whose writer is provably gone is not
|
|
152
|
+
// waited on at all.
|
|
153
|
+
const LOCK_STALE_MS = 10000;
|
|
154
|
+
const LOCK_RETRY_MS = 15;
|
|
155
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
156
|
+
|
|
157
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether a process is running.
|
|
161
|
+
*
|
|
162
|
+
* @param {number} pid
|
|
163
|
+
* @returns {boolean}
|
|
164
|
+
*/
|
|
165
|
+
function isAlive(pid) {
|
|
166
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
167
|
+
try {
|
|
168
|
+
process.kill(pid, 0);
|
|
169
|
+
return true;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
// EPERM: it exists, it just is not ours to signal.
|
|
172
|
+
return error.code === 'EPERM';
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Whether creating a lock failed only because somebody else holds it.
|
|
178
|
+
*
|
|
179
|
+
* EEXIST everywhere. On Windows, also EPERM and EACCES: a file another
|
|
180
|
+
* process has just deleted lingers as "delete pending" while anyone still
|
|
181
|
+
* has it open, and creating one by the same name is refused until it is
|
|
182
|
+
* gone -- the lock is being released, not withheld. CI caught it: two
|
|
183
|
+
* writers in separate processes, and one of them died of EPERM.
|
|
184
|
+
*
|
|
185
|
+
* @param {NodeJS.ErrnoException} error
|
|
186
|
+
*/
|
|
187
|
+
function lockIsBusy(error) {
|
|
188
|
+
if (error.code === 'EEXIST') return true;
|
|
189
|
+
return process.platform === 'win32' && (error.code === 'EPERM' || error.code === 'EACCES');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Whether a lock has been abandoned: the process that wrote it has gone, or
|
|
194
|
+
* it has been held far longer than any write takes.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} lock
|
|
197
|
+
* @returns {Promise<boolean>}
|
|
198
|
+
*/
|
|
199
|
+
async function isAbandoned(lock) {
|
|
200
|
+
const [content, stat] = await Promise.all([
|
|
201
|
+
fs.readFile(lock, 'utf-8').catch(() => null),
|
|
202
|
+
fs.stat(lock).catch(() => null)
|
|
203
|
+
]);
|
|
204
|
+
if (content === null || !stat) return false; // released meanwhile: just try again
|
|
205
|
+
const owner = Number.parseInt(content, 10);
|
|
206
|
+
if (Number.isInteger(owner) && owner > 0 && !isAlive(owner)) return true;
|
|
207
|
+
return Date.now() - stat.mtimeMs > LOCK_STALE_MS;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Remove a lock whose writer died holding it, and say whether it was removed.
|
|
212
|
+
*
|
|
213
|
+
* Two waiters can both find the same lock abandoned. If each simply removed
|
|
214
|
+
* it, the second would remove the lock the first had just taken, and both
|
|
215
|
+
* would write at once -- the lost reply the lock exists to prevent. So
|
|
216
|
+
* breaking a lock is done under a lock of its own, and the one holding it
|
|
217
|
+
* looks again before removing anything. Nothing but a breaker removes a lock
|
|
218
|
+
* that is not its own, and there is one breaker at a time.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} lock
|
|
221
|
+
* @returns {Promise<boolean>}
|
|
222
|
+
*/
|
|
223
|
+
async function breakAbandonedLock(lock) {
|
|
224
|
+
if (!(await isAbandoned(lock))) return false;
|
|
225
|
+
|
|
226
|
+
const breaker = `${lock}.break`;
|
|
227
|
+
try {
|
|
228
|
+
await (await fs.open(breaker, 'wx')).close();
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (!lockIsBusy(error)) throw error;
|
|
231
|
+
// Somebody else is breaking it. One who died doing so is cleared by age.
|
|
232
|
+
const stat = await fs.stat(breaker).catch(() => null);
|
|
233
|
+
if (stat && Date.now() - stat.mtimeMs > LOCK_STALE_MS) await fs.rm(breaker, { force: true });
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
if (!(await isAbandoned(lock))) return false;
|
|
239
|
+
await fs.rm(lock, { force: true });
|
|
240
|
+
return true;
|
|
241
|
+
} finally {
|
|
242
|
+
await fs.rm(breaker, { force: true });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Run `task` holding the lock on `file`, against every process on the machine.
|
|
248
|
+
*
|
|
249
|
+
* The page is no longer the only writer. An agent answering in a thread
|
|
250
|
+
* writes the same file from another process -- `reviewer channel`, run by
|
|
251
|
+
* Claude Code -- and a read-modify-write from each, interleaved, loses one of
|
|
252
|
+
* them. The lock is a sibling file created with `wx`, which the operating
|
|
253
|
+
* system creates atomically or refuses, on every platform Node runs on, and
|
|
254
|
+
* which holds the writer's pid so a lock left by a process that died can be
|
|
255
|
+
* recognised as such.
|
|
256
|
+
*
|
|
257
|
+
* @template T
|
|
258
|
+
* @param {string} file
|
|
259
|
+
* @param {() => Promise<T>} task
|
|
260
|
+
* @returns {Promise<T>}
|
|
261
|
+
*/
|
|
262
|
+
async function withFileLock(file, task) {
|
|
263
|
+
const lock = `${file}.lock`;
|
|
264
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
265
|
+
|
|
266
|
+
for (;;) {
|
|
267
|
+
try {
|
|
268
|
+
const handle = await fs.open(lock, 'wx');
|
|
269
|
+
try {
|
|
270
|
+
await handle.writeFile(`${process.pid}\n`);
|
|
271
|
+
} finally {
|
|
272
|
+
await handle.close();
|
|
273
|
+
}
|
|
274
|
+
break;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (!lockIsBusy(error)) throw error;
|
|
277
|
+
if (error.code === 'EEXIST' && await breakAbandonedLock(lock)) continue;
|
|
278
|
+
if (Date.now() > deadline) {
|
|
279
|
+
if (error.code !== 'EEXIST') throw error;
|
|
280
|
+
throw new Error(`Timed out waiting for ${path.basename(lock)}; if no reviewer is running, delete it.`);
|
|
281
|
+
}
|
|
282
|
+
await sleep(LOCK_RETRY_MS);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
return await task();
|
|
288
|
+
} finally {
|
|
289
|
+
await fs.rm(lock, { force: true });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Replace `file` in one step, so no reader ever sees half of it.
|
|
295
|
+
*
|
|
296
|
+
* A reader in another process -- an agent watching the review -- would
|
|
297
|
+
* otherwise be able to catch the file truncated mid-write and parse nothing.
|
|
298
|
+
* Windows refuses to rename over a file another process has open for a
|
|
299
|
+
* moment, so that is retried briefly rather than failing the save.
|
|
300
|
+
*
|
|
301
|
+
* @param {string} file
|
|
302
|
+
* @param {string} content
|
|
303
|
+
*/
|
|
304
|
+
async function writeFileAtomic(file, content) {
|
|
305
|
+
const temporary = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
306
|
+
await fs.writeFile(temporary, content);
|
|
307
|
+
for (let attempt = 0; ; attempt++) {
|
|
308
|
+
try {
|
|
309
|
+
await fs.rename(temporary, file);
|
|
310
|
+
return;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (attempt < 20 && ['EPERM', 'EACCES', 'EBUSY'].includes(error.code)) {
|
|
313
|
+
await sleep(10);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
await fs.rm(temporary, { force: true });
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Writes from this process to one file go one at a time, so they queue here
|
|
323
|
+
// instead of polling for the lock against each other.
|
|
324
|
+
/** @type {Map<string, Promise<void>>} */
|
|
325
|
+
const queues = new Map();
|
|
326
|
+
|
|
327
|
+
function serialize(file, task) {
|
|
328
|
+
const previous = queues.get(file) ?? Promise.resolve();
|
|
329
|
+
const run = previous.then(task, task);
|
|
330
|
+
const tail = run.then(() => {}, () => {});
|
|
331
|
+
queues.set(file, tail);
|
|
332
|
+
tail.then(() => {
|
|
333
|
+
if (queues.get(file) === tail) queues.delete(file);
|
|
334
|
+
});
|
|
335
|
+
return run;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* @typedef {object} SavedReview
|
|
340
|
+
* @property {import('./comments').Comment[]} comments keyed
|
|
341
|
+
* @property {string|null} mode
|
|
342
|
+
* @property {object|null} range
|
|
343
|
+
* @property {boolean} [untracked]
|
|
344
|
+
* @property {string|null} lastUpdated
|
|
345
|
+
*/
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Change a repository's saved comments, safely against every other writer.
|
|
349
|
+
*
|
|
350
|
+
* Takes the lock, reads what is on disk now, and hands it to `update`, which
|
|
351
|
+
* returns the comments to write -- or null to write nothing, which is how a
|
|
352
|
+
* conditional save turns itself down. What it returns is written in one step
|
|
353
|
+
* with a `lastUpdated` strictly after the one it replaced. The scope fields
|
|
354
|
+
* (`mode`, `range`, `untracked`) are kept from disk unless `update` supplies
|
|
355
|
+
* them: a reply in a thread does not change what the review is of.
|
|
356
|
+
*
|
|
357
|
+
* @param {string} reviewsDir
|
|
358
|
+
* @param {string} repoPath
|
|
359
|
+
* @param {(current: SavedReview) => ({comments: object[], scope?: {mode: string|null, range: object|null, untracked?: boolean}}|null|Promise<object|null>)} update
|
|
360
|
+
* @returns {Promise<{written: boolean, comments: object[], lastUpdated: string|null}>}
|
|
361
|
+
*/
|
|
362
|
+
async function updateSavedComments(reviewsDir, repoPath, update) {
|
|
363
|
+
const file = path.join(reviewsDir, commentsFilename(repoPath));
|
|
364
|
+
|
|
365
|
+
return serialize(file, async () => {
|
|
366
|
+
await fs.mkdir(reviewsDir, { recursive: true });
|
|
367
|
+
|
|
368
|
+
return withFileLock(file, async () => {
|
|
369
|
+
const current = await readSavedComments(reviewsDir, repoPath);
|
|
370
|
+
const change = await update(current);
|
|
371
|
+
if (!change) return { written: false, comments: current.comments, lastUpdated: current.lastUpdated };
|
|
372
|
+
|
|
373
|
+
const scope = change.scope ?? current;
|
|
374
|
+
const lastUpdated = nextLastUpdated(current.lastUpdated ?? null);
|
|
375
|
+
const data = {
|
|
376
|
+
repoPath,
|
|
377
|
+
lastUpdated,
|
|
378
|
+
mode: scope.mode ?? null,
|
|
379
|
+
range: scope.range ?? null,
|
|
380
|
+
...(scope.mode === 'working' && scope.untracked === false ? { untracked: false } : {}),
|
|
381
|
+
comments: change.comments
|
|
382
|
+
};
|
|
383
|
+
await writeFileAtomic(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
384
|
+
|
|
385
|
+
return { written: true, comments: change.comments, lastUpdated };
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
module.exports = {
|
|
391
|
+
readSavedComments,
|
|
392
|
+
updateSavedComments,
|
|
393
|
+
nextLastUpdated,
|
|
394
|
+
withFileLock,
|
|
395
|
+
writeFileAtomic,
|
|
396
|
+
isAlive,
|
|
397
|
+
ReviewOwnershipError,
|
|
398
|
+
belongsTo
|
|
399
|
+
};
|