revspec 0.7.0 → 0.7.1
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/package.json +1 -1
- package/src/cli/watch.ts +57 -21
package/package.json
CHANGED
package/src/cli/watch.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "fs";
|
|
2
2
|
import { watch as fsWatch } from "fs";
|
|
3
3
|
import { resolve, dirname, basename, join } from "path";
|
|
4
4
|
import {
|
|
@@ -8,6 +8,13 @@ import {
|
|
|
8
8
|
import type { Thread } from "../protocol/types";
|
|
9
9
|
import { replayEventsToThreads } from "../protocol/live-events";
|
|
10
10
|
|
|
11
|
+
/** Atomic write: write to .tmp then rename (POSIX rename is atomic) */
|
|
12
|
+
function atomicWriteFileSync(filePath: string, content: string): void {
|
|
13
|
+
const tmpPath = filePath + ".tmp";
|
|
14
|
+
writeFileSync(tmpPath, content, "utf8");
|
|
15
|
+
renameSync(tmpPath, filePath);
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
export async function runWatch(specFile: string): Promise<void> {
|
|
12
19
|
// Resolve and validate spec path
|
|
13
20
|
const specPath = resolve(specFile);
|
|
@@ -22,7 +29,6 @@ export async function runWatch(specFile: string): Promise<void> {
|
|
|
22
29
|
const jsonlPath = join(dir, `${base}.review.jsonl`);
|
|
23
30
|
const offsetPath = join(dir, `${base}.review.offset`);
|
|
24
31
|
const lockPath = join(dir, `${base}.review.lock`);
|
|
25
|
-
const reviewPath = join(dir, `${base}.review.json`);
|
|
26
32
|
|
|
27
33
|
// Handle lock file
|
|
28
34
|
if (existsSync(lockPath)) {
|
|
@@ -46,14 +52,21 @@ export async function runWatch(specFile: string): Promise<void> {
|
|
|
46
52
|
// Create/overwrite lock with current PID
|
|
47
53
|
writeFileSync(lockPath, String(process.pid), "utf8");
|
|
48
54
|
|
|
49
|
-
// Read offset from offset file
|
|
55
|
+
// Read offset and last processed submit timestamp from offset file
|
|
50
56
|
let offset = 0;
|
|
57
|
+
let lastSubmitTs = 0;
|
|
51
58
|
if (existsSync(offsetPath)) {
|
|
52
|
-
const
|
|
53
|
-
const parsed = parseInt(
|
|
59
|
+
const lines = readFileSync(offsetPath, "utf8").trim().split("\n");
|
|
60
|
+
const parsed = parseInt(lines[0], 10);
|
|
54
61
|
if (!isNaN(parsed)) {
|
|
55
62
|
offset = parsed;
|
|
56
63
|
}
|
|
64
|
+
if (lines.length > 1) {
|
|
65
|
+
const parsedTs = parseInt(lines[1], 10);
|
|
66
|
+
if (!isNaN(parsedTs)) {
|
|
67
|
+
lastSubmitTs = parsedTs;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
// Read spec lines for context
|
|
@@ -66,11 +79,11 @@ export async function runWatch(specFile: string): Promise<void> {
|
|
|
66
79
|
const result = processNewEvents(
|
|
67
80
|
jsonlPath,
|
|
68
81
|
offsetPath,
|
|
69
|
-
lockPath,
|
|
70
|
-
reviewPath,
|
|
71
82
|
specPath,
|
|
72
83
|
specLines,
|
|
73
|
-
offset
|
|
84
|
+
offset,
|
|
85
|
+
lastSubmitTs,
|
|
86
|
+
true // always check recovery in non-blocking (one-shot)
|
|
74
87
|
);
|
|
75
88
|
if (result.approved) {
|
|
76
89
|
console.log("Review approved.");
|
|
@@ -83,20 +96,22 @@ export async function runWatch(specFile: string): Promise<void> {
|
|
|
83
96
|
|
|
84
97
|
// Blocking mode: watch for JSONL changes
|
|
85
98
|
let processing = false;
|
|
99
|
+
let firstPoll = true; // only run crash recovery on first poll
|
|
86
100
|
|
|
87
|
-
const handleChange =
|
|
101
|
+
const handleChange = () => {
|
|
88
102
|
if (processing) return;
|
|
89
103
|
processing = true;
|
|
90
104
|
try {
|
|
91
105
|
const result = processNewEvents(
|
|
92
106
|
jsonlPath,
|
|
93
107
|
offsetPath,
|
|
94
|
-
lockPath,
|
|
95
|
-
reviewPath,
|
|
96
108
|
specPath,
|
|
97
109
|
specLines,
|
|
98
|
-
offset
|
|
110
|
+
offset,
|
|
111
|
+
lastSubmitTs,
|
|
112
|
+
firstPoll
|
|
99
113
|
);
|
|
114
|
+
firstPoll = false;
|
|
100
115
|
|
|
101
116
|
if (result.approved) {
|
|
102
117
|
console.log("Review approved.");
|
|
@@ -110,8 +125,15 @@ export async function runWatch(specFile: string): Promise<void> {
|
|
|
110
125
|
process.exit(0);
|
|
111
126
|
}
|
|
112
127
|
|
|
113
|
-
// Update
|
|
128
|
+
// Update state for next poll iteration
|
|
114
129
|
offset = result.newOffset;
|
|
130
|
+
if (existsSync(offsetPath)) {
|
|
131
|
+
const lines = readFileSync(offsetPath, "utf8").trim().split("\n");
|
|
132
|
+
if (lines.length > 1) {
|
|
133
|
+
const parsedTs = parseInt(lines[1], 10);
|
|
134
|
+
if (!isNaN(parsedTs)) lastSubmitTs = parsedTs;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
115
137
|
} finally {
|
|
116
138
|
processing = false;
|
|
117
139
|
}
|
|
@@ -168,11 +190,11 @@ interface ProcessResult {
|
|
|
168
190
|
function processNewEvents(
|
|
169
191
|
jsonlPath: string,
|
|
170
192
|
offsetPath: string,
|
|
171
|
-
lockPath: string,
|
|
172
|
-
reviewPath: string,
|
|
173
193
|
specPath: string,
|
|
174
194
|
specLines: string[],
|
|
175
|
-
offset: number
|
|
195
|
+
offset: number,
|
|
196
|
+
lastSubmitTs: number,
|
|
197
|
+
checkRecovery: boolean
|
|
176
198
|
): ProcessResult {
|
|
177
199
|
if (!existsSync(jsonlPath)) {
|
|
178
200
|
return { approved: false, output: "", newOffset: offset };
|
|
@@ -180,11 +202,17 @@ function processNewEvents(
|
|
|
180
202
|
|
|
181
203
|
const { events, newOffset } = readEventsFromOffset(jsonlPath, offset);
|
|
182
204
|
|
|
183
|
-
// Recovery: detect pending unprocessed submit
|
|
184
|
-
|
|
205
|
+
// Recovery: detect pending unprocessed submit (only on first poll to avoid
|
|
206
|
+
// re-reading the entire JSONL every 500ms in blocking mode)
|
|
207
|
+
if (events.length === 0 && checkRecovery) {
|
|
185
208
|
const { events: allEvents } = readEventsFromOffset(jsonlPath, 0);
|
|
186
209
|
const lastSubmitIdx = allEvents.findLastIndex(e => e.type === "submit");
|
|
187
210
|
if (lastSubmitIdx >= 0) {
|
|
211
|
+
const lastSubmitEvent = allEvents[lastSubmitIdx];
|
|
212
|
+
// If we already output this submit, skip (not a crash)
|
|
213
|
+
if (lastSubmitEvent.ts === lastSubmitTs) {
|
|
214
|
+
return { approved: false, output: "", newOffset: offset };
|
|
215
|
+
}
|
|
188
216
|
const afterSubmit = allEvents.slice(lastSubmitIdx + 1);
|
|
189
217
|
const hasNewActivity = afterSubmit.some(e =>
|
|
190
218
|
e.type === "comment" || e.type === "reply" ||
|
|
@@ -195,14 +223,20 @@ function processNewEvents(
|
|
|
195
223
|
const currentRoundThreads = replayEventsToThreads(allEvents.slice(roundStart));
|
|
196
224
|
const resolved = currentRoundThreads.filter(t => t.status === "resolved");
|
|
197
225
|
const output = formatSubmitOutput(resolved, specPath);
|
|
226
|
+
// Record this submit's ts so we don't re-output it
|
|
227
|
+
atomicWriteFileSync(offsetPath, `${offset}\n${lastSubmitEvent.ts}`);
|
|
198
228
|
return { approved: false, output, newOffset: offset };
|
|
199
229
|
}
|
|
200
230
|
}
|
|
201
231
|
return { approved: false, output: "", newOffset: offset };
|
|
202
232
|
}
|
|
203
233
|
|
|
234
|
+
if (events.length === 0) {
|
|
235
|
+
return { approved: false, output: "", newOffset: offset };
|
|
236
|
+
}
|
|
237
|
+
|
|
204
238
|
// Save new offset
|
|
205
|
-
|
|
239
|
+
atomicWriteFileSync(offsetPath, String(newOffset));
|
|
206
240
|
|
|
207
241
|
// Check for approve event
|
|
208
242
|
const hasApprove = events.some((e) => e.type === "approve");
|
|
@@ -211,13 +245,15 @@ function processNewEvents(
|
|
|
211
245
|
}
|
|
212
246
|
|
|
213
247
|
// Check for submit event — priority over session-end
|
|
214
|
-
const
|
|
215
|
-
if (
|
|
248
|
+
const submitEvent = events.findLast((e) => e.type === "submit");
|
|
249
|
+
if (submitEvent) {
|
|
216
250
|
const { events: allEvents } = readEventsFromOffset(jsonlPath, 0);
|
|
217
251
|
const roundStart = findCurrentRoundStartIndex(allEvents);
|
|
218
252
|
const currentRoundThreads = replayEventsToThreads(allEvents.slice(roundStart));
|
|
219
253
|
const resolved = currentRoundThreads.filter(t => t.status === "resolved");
|
|
220
254
|
const output = formatSubmitOutput(resolved, specPath);
|
|
255
|
+
// Record submit ts for crash recovery dedup
|
|
256
|
+
atomicWriteFileSync(offsetPath, `${newOffset}\n${submitEvent.ts}`);
|
|
221
257
|
return { approved: false, output, newOffset };
|
|
222
258
|
}
|
|
223
259
|
|