@a9n-shoji/rvw 0.2.1 → 0.2.3

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 (103) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +24 -4
  3. package/dist/cli.mjs +188 -19
  4. package/dist/cli.mjs.map +4 -4
  5. package/dist/web/assets/DocumentViewer-B4LdSL1l.js +1560 -0
  6. package/dist/web/assets/{MarkdownTable-DbDc9VoP.js → MarkdownTable-CdbKPft2.js} +1 -1
  7. package/dist/web/assets/WalkthroughViewer-D1LlSQw2.js +1 -0
  8. package/dist/web/assets/{abnfDiagram-N423BO3Z-fQDP4qYL.js → abnfDiagram-N423BO3Z-CE1nZSC9.js} +1 -1
  9. package/dist/web/assets/architecture-TIHT7OUA-edLV2Zq-.js +1 -0
  10. package/dist/web/assets/{architectureDiagram-T3A2C74G-D3Aon3Nd.js → architectureDiagram-T3A2C74G-B88qYFUo.js} +1 -1
  11. package/dist/web/assets/{blockDiagram-VBNYF7ZC-DS1DcAqy.js → blockDiagram-VBNYF7ZC-BaX0rhbb.js} +1 -1
  12. package/dist/web/assets/{c4Diagram-5PPSVZJV-BHQWz7l2.js → c4Diagram-5PPSVZJV-C1ppBaEC.js} +1 -1
  13. package/dist/web/assets/channel-J8FHSJDG.js +1 -0
  14. package/dist/web/assets/{chunk-2GRJ4B5K-CaahxDVT.js → chunk-2GRJ4B5K-D2TTocfh.js} +1 -1
  15. package/dist/web/assets/{chunk-4I5QYGJK-Bat5hdXD.js → chunk-4I5QYGJK-ikT25rPN.js} +1 -1
  16. package/dist/web/assets/{chunk-5RXB4S5H-jyonN41f.js → chunk-5RXB4S5H-CGlPaulj.js} +1 -1
  17. package/dist/web/assets/{chunk-6Q2QTUOP-BBo9TYzL.js → chunk-6Q2QTUOP-BuuMeCo2.js} +1 -1
  18. package/dist/web/assets/{chunk-7Z6QIM7H-CGSwsKhC.js → chunk-7Z6QIM7H-DT3BAZ4L.js} +1 -1
  19. package/dist/web/assets/{chunk-GF5L2VYU-Cuca3bAY.js → chunk-GF5L2VYU-mIdlxzfr.js} +1 -1
  20. package/dist/web/assets/{chunk-I66GZJ75-DRdqLSyH.js → chunk-I66GZJ75-CGl1hx4J.js} +1 -1
  21. package/dist/web/assets/{chunk-JQJVKLGR-8xhLkhs2.js → chunk-JQJVKLGR-BLG6gAfx.js} +1 -1
  22. package/dist/web/assets/{chunk-KBJHAD2P-oOV-3e5-.js → chunk-KBJHAD2P-D_MPzBJx.js} +1 -1
  23. package/dist/web/assets/{chunk-NSK5VX7P-CXit1l3q.js → chunk-NSK5VX7P-CiYiU8wP.js} +1 -1
  24. package/dist/web/assets/{chunk-QR6OTTB3-DcdCU-01.js → chunk-QR6OTTB3-BK8Tnvcc.js} +1 -1
  25. package/dist/web/assets/{chunk-UBXNYLIW-i72Hgljr.js → chunk-UBXNYLIW-BuzttTFk.js} +1 -1
  26. package/dist/web/assets/{chunk-W5SLKNZC-DetAQo7M.js → chunk-W5SLKNZC-UidmFHe2.js} +1 -1
  27. package/dist/web/assets/{chunk-WRU74C26-DDUvJFNs.js → chunk-WRU74C26-nox7WFnh.js} +1 -1
  28. package/dist/web/assets/classDiagram-JCYQIIEL-CmTWSnH0.js +1 -0
  29. package/dist/web/assets/classDiagram-v2-OCEON4UE-CmTWSnH0.js +1 -0
  30. package/dist/web/assets/{cynefin-VYW2F7L2-fd-v6ani.js → cynefin-VYW2F7L2-wDMibS5s.js} +1 -1
  31. package/dist/web/assets/{cynefinDiagram-MW4NZA55-DRsVpAC3.js → cynefinDiagram-MW4NZA55-CBTrdvOE.js} +1 -1
  32. package/dist/web/assets/{dagre-VZM6K2ZE-BnRSs_fe.js → dagre-VZM6K2ZE-Bbgn4Rr7.js} +1 -1
  33. package/dist/web/assets/{diagram-7IWD3JNH-D1g0PR4y.js → diagram-7IWD3JNH-Bm9AuXb9.js} +1 -1
  34. package/dist/web/assets/{diagram-B4RE2ZJO-DuDKsQLp.js → diagram-B4RE2ZJO-D3lZ-r-H.js} +1 -1
  35. package/dist/web/assets/{diagram-LBJQPF4R-COpzgGe7.js → diagram-LBJQPF4R-D-GaynX7.js} +1 -1
  36. package/dist/web/assets/{diagram-Q27KOJAE-DR2vRbPE.js → diagram-Q27KOJAE-BeZRiu1d.js} +1 -1
  37. package/dist/web/assets/{diagram-UB23O5K3-hs4VGPP7.js → diagram-UB23O5K3-C3GP7iMe.js} +1 -1
  38. package/dist/web/assets/{ebnfDiagram-BXEA7PRR-B_4NlK9q.js → ebnfDiagram-BXEA7PRR-BJWg0NVU.js} +1 -1
  39. package/dist/web/assets/{erDiagram-JOGREHBK-JyoZ3U-o.js → erDiagram-JOGREHBK-XPmhUeB_.js} +1 -1
  40. package/dist/web/assets/eventmodeling-45OFAUF4-C3c4qddw.js +1 -0
  41. package/dist/web/assets/flowDiagram-UKHOOZJN-DMA-a1zf.js +1 -0
  42. package/dist/web/assets/{ganttDiagram-PKOTCBZU-f37I0Twm.js → ganttDiagram-PKOTCBZU-CL-xEo-M.js} +1 -1
  43. package/dist/web/assets/{gitGraph-TEB2WS4Q-B_QWjr7j.js → gitGraph-TEB2WS4Q-BVRyuIx3.js} +1 -1
  44. package/dist/web/assets/{gitGraphDiagram-DS77QQ5N-AZsaC0hu.js → gitGraphDiagram-DS77QQ5N-BVNIEupW.js} +1 -1
  45. package/dist/web/assets/index-D8GJBw23.js +384 -0
  46. package/dist/web/assets/{index-kK3hngnz.css → index-DMD2vvQW.css} +1 -1
  47. package/dist/web/assets/{info-DKCQHKI2-BRfisbEI.js → info-DKCQHKI2-BKBUVpfc.js} +1 -1
  48. package/dist/web/assets/{infoDiagram-6WML65LV-CaIb64N5.js → infoDiagram-6WML65LV-_dTFnzSE.js} +1 -1
  49. package/dist/web/assets/{ishikawaDiagram-WSZJBQD7-BoJrcTja.js → ishikawaDiagram-WSZJBQD7-DIRgD4hO.js} +1 -1
  50. package/dist/web/assets/{journeyDiagram-NVQOT4AX-CRR1dZy8.js → journeyDiagram-NVQOT4AX-bp7nZrfR.js} +1 -1
  51. package/dist/web/assets/{kanban-definition-27J2QSJJ-D5skDuP4.js → kanban-definition-27J2QSJJ-C6mZIo8S.js} +1 -1
  52. package/dist/web/assets/{line-BKKx0vSh.js → line-DWTym7eJ.js} +1 -1
  53. package/dist/web/assets/{mermaid-parser.core-DvZnIth0.js → mermaid-parser.core-CbecsNA1.js} +3 -3
  54. package/dist/web/assets/{mermaid.core-hykiOF_o.js → mermaid.core-CdRuLyYI.js} +4 -4
  55. package/dist/web/assets/{mindmap-definition-FAOFIHXS-BN2LQ4Yn.js → mindmap-definition-FAOFIHXS-CU2P1z-9.js} +1 -1
  56. package/dist/web/assets/{packet-7NZHBO7P-CS5I0NlV.js → packet-7NZHBO7P-CZsup6Od.js} +1 -1
  57. package/dist/web/assets/{pegDiagram-VL7TDLO6-WvOdhbxo.js → pegDiagram-VL7TDLO6-DTTyttpe.js} +1 -1
  58. package/dist/web/assets/{pie-RZYD4A2V-BwNAZrQ1.js → pie-RZYD4A2V-DgrIiWZA.js} +1 -1
  59. package/dist/web/assets/{pieDiagram-7S7Q4E2Y-CbyrZjm7.js → pieDiagram-7S7Q4E2Y-BBNFyPT1.js} +1 -1
  60. package/dist/web/assets/{quadrantDiagram-CIZ2JOQS-CW9YBxAO.js → quadrantDiagram-CIZ2JOQS-ejp-Aa2l.js} +1 -1
  61. package/dist/web/assets/{radar-I7S5WNFK-DBoCLDNH.js → radar-I7S5WNFK-Cp_hfjrI.js} +1 -1
  62. package/dist/web/assets/{railroad-3IZDKUUU-BOjpYyDz.js → railroad-3IZDKUUU-Dw-oMal2.js} +1 -1
  63. package/dist/web/assets/railroad-abnf-AHOZXSZD-BN-ZBhE0.js +1 -0
  64. package/dist/web/assets/railroad-ebnf-EBAXGLYW-CG97zyI2.js +1 -0
  65. package/dist/web/assets/railroad-peg-LSFZ7HO6-DHLQ1evQ.js +1 -0
  66. package/dist/web/assets/{railroadDiagram-AXF67PYL-BdOOOq8o.js → railroadDiagram-AXF67PYL-DkBUaj6Z.js} +1 -1
  67. package/dist/web/assets/{requirementDiagram-LRYGKXZP-DIYlSrTb.js → requirementDiagram-LRYGKXZP-Dff1ex_h.js} +1 -1
  68. package/dist/web/assets/{sankeyDiagram-W5VNT64P-Cc1IGxp_.js → sankeyDiagram-W5VNT64P-DptSL9Bc.js} +1 -1
  69. package/dist/web/assets/{sequenceDiagram-SI44F4Z6-CJsPO9Z9.js → sequenceDiagram-SI44F4Z6-DEnPcs3U.js} +1 -1
  70. package/dist/web/assets/{stateDiagram-OKZ733FA-B033gpHZ.js → stateDiagram-OKZ733FA-BwJO1MKA.js} +1 -1
  71. package/dist/web/assets/stateDiagram-v2-UEYNNEHI-LaSNqvWy.js +1 -0
  72. package/dist/web/assets/{swimlanes-SLNWSIFB-C0Tstg7e.js → swimlanes-SLNWSIFB-BosKiwZA.js} +1 -1
  73. package/dist/web/assets/swimlanesDiagram-ULZ7WXOC-C5OBtBlv.js +8 -0
  74. package/dist/web/assets/{timeline-definition-Z64GVDOM-BA3y-16W.js → timeline-definition-Z64GVDOM-C5isAn4X.js} +1 -1
  75. package/dist/web/assets/{treeView-QDETBFTQ-BUNq-JXr.js → treeView-QDETBFTQ-zEyUXRc4.js} +1 -1
  76. package/dist/web/assets/{treemap-6X3UGDF4-FIR_NbUo.js → treemap-6X3UGDF4-B-X0EQSE.js} +1 -1
  77. package/dist/web/assets/{vennDiagram-T6HMQDX7-IeFFjnlq.js → vennDiagram-T6HMQDX7-QMdGieCa.js} +1 -1
  78. package/dist/web/assets/{wardley-OPB4EBWU-DQAX4LKo.js → wardley-OPB4EBWU-BaeAF8d8.js} +1 -1
  79. package/dist/web/assets/{wardleyDiagram-T6FBY63Y-guGPPMAG.js → wardleyDiagram-T6FBY63Y-DTQ_uLUq.js} +1 -1
  80. package/dist/web/assets/{xychartDiagram-ELKLHX3M-Cj0v359_.js → xychartDiagram-ELKLHX3M-BMdBWzib.js} +1 -1
  81. package/dist/web/index.html +2 -2
  82. package/package.json +17 -14
  83. package/skills/rvw/SKILL.md +56 -15
  84. package/skills/rvw-watch-comments/SKILL.md +117 -47
  85. package/skills/rvw-watch-comments/agents/openai.yaml +2 -2
  86. package/skills/rvw-watch-comments/scripts/auto-ack.mjs +0 -0
  87. package/skills/rvw-watch-comments/scripts/preflight.mjs +0 -0
  88. package/skills/rvw-watch-comments/scripts/watch-driver.mjs +223 -48
  89. package/skills/rvw-watch-comments/scripts/watch-state.mjs +125 -64
  90. package/dist/web/assets/DocumentViewer-DvrDlIpR.js +0 -1560
  91. package/dist/web/assets/WalkthroughViewer-aDUeKC3B.js +0 -1
  92. package/dist/web/assets/architecture-TIHT7OUA-CuFS9GkN.js +0 -1
  93. package/dist/web/assets/channel-CTWz7tRI.js +0 -1
  94. package/dist/web/assets/classDiagram-JCYQIIEL-BJQv9QuO.js +0 -1
  95. package/dist/web/assets/classDiagram-v2-OCEON4UE-BJQv9QuO.js +0 -1
  96. package/dist/web/assets/eventmodeling-45OFAUF4-CfUhQK5g.js +0 -1
  97. package/dist/web/assets/flowDiagram-UKHOOZJN-FFZ82SPa.js +0 -1
  98. package/dist/web/assets/index-CY_JoUL3.js +0 -384
  99. package/dist/web/assets/railroad-abnf-AHOZXSZD-DQhDS2pf.js +0 -1
  100. package/dist/web/assets/railroad-ebnf-EBAXGLYW-Dtt2kOaN.js +0 -1
  101. package/dist/web/assets/railroad-peg-LSFZ7HO6-nfH8zno6.js +0 -1
  102. package/dist/web/assets/stateDiagram-v2-UEYNNEHI-wS01qj3t.js +0 -1
  103. package/dist/web/assets/swimlanesDiagram-ULZ7WXOC-DoAqfWAd.js +0 -8
@@ -1,6 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { randomUUID } from "node:crypto";
3
4
  import { spawn } from "node:child_process";
5
+ import {
6
+ chmodSync,
7
+ linkSync,
8
+ lstatSync,
9
+ readFileSync,
10
+ realpathSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
4
14
  import path from "node:path";
5
15
  import { StringDecoder } from "node:string_decoder";
6
16
  import { fileURLToPath } from "node:url";
@@ -10,9 +20,13 @@ const EXIT_WATCH = 20;
10
20
  const EXIT_SEQUENCE = 21;
11
21
  const EXIT_INGEST = 22;
12
22
  const EXIT_AUTO_ACK = 23;
23
+ const EXIT_ALREADY_RUNNING = 24;
24
+ const DEFAULT_MAX_IN_FLIGHT = 1;
25
+ const DEFAULT_AUTO_ACK_POLL_MS = 250;
13
26
  const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
14
27
  const stateScript = path.join(scriptDirectory, "watch-state.mjs");
15
28
  const autoAckScript = path.join(scriptDirectory, "auto-ack.mjs");
29
+ const DRIVER_LOCK_SUFFIX = ".watch-driver.lock";
16
30
 
17
31
  class DriverError extends Error {
18
32
  constructor(message, exitCode, details = null) {
@@ -25,12 +39,27 @@ class DriverError extends Error {
25
39
  function parseArguments(values) {
26
40
  let state = null;
27
41
  let autoAck = false;
42
+ let maxInFlight = DEFAULT_MAX_IN_FLIGHT;
43
+ let maxInFlightProvided = false;
28
44
  for (let index = 0; index < values.length; index += 1) {
29
45
  const value = values[index];
30
46
  if (value === "--auto-ack") {
31
47
  autoAck = true;
32
48
  continue;
33
49
  }
50
+ if (value === "--max-in-flight") {
51
+ const next = values[index + 1];
52
+ if (!next || next.startsWith("--")) {
53
+ throw new DriverError("--max-in-flight requires an integer", EXIT_WATCH);
54
+ }
55
+ maxInFlight = Number(next);
56
+ maxInFlightProvided = true;
57
+ if (!Number.isSafeInteger(maxInFlight) || maxInFlight < 1 || maxInFlight > 64) {
58
+ throw new DriverError("--max-in-flight must be an integer from 1 through 64", EXIT_WATCH);
59
+ }
60
+ index += 1;
61
+ continue;
62
+ }
34
63
  if (value === "--state") {
35
64
  const next = values[index + 1];
36
65
  if (!next || next.startsWith("--")) {
@@ -47,7 +76,10 @@ function parseArguments(values) {
47
76
  throw new DriverError(`Unexpected argument: ${value}`, EXIT_WATCH);
48
77
  }
49
78
  if (!state) throw new DriverError("Pass the task state database path", EXIT_WATCH);
50
- return { state: path.resolve(state), autoAck };
79
+ if (!autoAck && maxInFlightProvided) {
80
+ throw new DriverError("--max-in-flight requires --auto-ack", EXIT_WATCH);
81
+ }
82
+ return { state: path.resolve(state), autoAck, maxInFlight };
51
83
  }
52
84
 
53
85
  async function runNodeScript(script, args, input) {
@@ -86,12 +118,108 @@ function write(value) {
86
118
  process.stdout.write(`${JSON.stringify(value)}\n`);
87
119
  }
88
120
 
89
- async function dispatchAutoAck(state, pullRequest) {
90
- const pending = await stateCommand(state, "list");
91
- if (!pending.pending.some((batch) => batch.pullRequest === pullRequest)) {
92
- write({ ok: true, type: "queued", pullRequest, reason: "batch-not-yet-eligible" });
93
- return;
121
+ function fileIdentity(filePath) {
122
+ try {
123
+ const stat = lstatSync(filePath);
124
+ return { dev: stat.dev, ino: stat.ino };
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ function unlinkIfOwned(filePath, identity) {
131
+ const current = fileIdentity(filePath);
132
+ if (!identity || !current || current.dev !== identity.dev || current.ino !== identity.ino) return;
133
+ try {
134
+ unlinkSync(filePath);
135
+ } catch (error) {
136
+ if (error.code !== "ENOENT") throw error;
137
+ }
138
+ }
139
+
140
+ function processIsAlive(pid) {
141
+ if (!Number.isInteger(pid) || pid <= 0) return false;
142
+ try {
143
+ process.kill(pid, 0);
144
+ return true;
145
+ } catch (error) {
146
+ return error.code !== "ESRCH";
147
+ }
148
+ }
149
+
150
+ function lockOwner(lockPath) {
151
+ try {
152
+ const value = JSON.parse(readFileSync(lockPath, "utf8"));
153
+ return Number.isInteger(value?.pid) && value.pid > 0 ? value : null;
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ function acquireDriverLock(state) {
160
+ const canonicalState = realpathSync(state);
161
+ const lockPath = `${canonicalState}${DRIVER_LOCK_SUFFIX}`;
162
+ for (let attempt = 0; attempt < 2; attempt += 1) {
163
+ const stagingPath = `${lockPath}.${process.pid}.${randomUUID()}.tmp`;
164
+ try {
165
+ writeFileSync(
166
+ stagingPath,
167
+ `${JSON.stringify({ pid: process.pid, state: canonicalState, startedAt: new Date().toISOString() })}\n`,
168
+ { flag: "wx", mode: 0o600 },
169
+ );
170
+ chmodSync(stagingPath, 0o600);
171
+ linkSync(stagingPath, lockPath);
172
+ } catch (error) {
173
+ try {
174
+ unlinkSync(stagingPath);
175
+ } catch (unlinkError) {
176
+ if (unlinkError.code !== "ENOENT") throw unlinkError;
177
+ }
178
+ if (error.code !== "EEXIST") throw error;
179
+ const observedIdentity = fileIdentity(lockPath);
180
+ const owner = lockOwner(lockPath);
181
+ // Preserve unreadable locks rather than risking two live drivers.
182
+ if (!owner || processIsAlive(owner.pid)) {
183
+ throw new DriverError(
184
+ "Another watch-driver process already owns this task state",
185
+ EXIT_ALREADY_RUNNING,
186
+ {
187
+ state: canonicalState,
188
+ lockPath,
189
+ ownerPid: owner?.pid ?? null,
190
+ },
191
+ );
192
+ }
193
+ unlinkIfOwned(lockPath, observedIdentity);
194
+ continue;
195
+ }
196
+ try {
197
+ const identity = fileIdentity(lockPath);
198
+ if (!identity) throw new Error("Could not verify the watch-driver owner lock");
199
+ unlinkSync(stagingPath);
200
+ return { identity, path: lockPath };
201
+ } catch (error) {
202
+ const stagingIdentity = fileIdentity(stagingPath);
203
+ unlinkIfOwned(lockPath, stagingIdentity);
204
+ try {
205
+ unlinkSync(stagingPath);
206
+ } catch (unlinkError) {
207
+ if (unlinkError.code !== "ENOENT") throw unlinkError;
208
+ }
209
+ throw error;
210
+ }
94
211
  }
212
+ throw new DriverError("Could not acquire the watch-driver owner lock", EXIT_ALREADY_RUNNING, {
213
+ state: canonicalState,
214
+ lockPath,
215
+ });
216
+ }
217
+
218
+ function releaseDriverLock(lock) {
219
+ unlinkIfOwned(lock.path, lock.identity);
220
+ }
221
+
222
+ async function dispatchAutoAck(state, pullRequest) {
95
223
  const result = await runNodeScript(autoAckScript, [
96
224
  "--state",
97
225
  state,
@@ -104,14 +232,21 @@ async function dispatchAutoAck(state, pullRequest) {
104
232
  write({ ...result.json, type: "batch-acknowledged" });
105
233
  }
106
234
 
107
- async function dispatchPendingAutoAcks(state) {
108
- const pending = await stateCommand(state, "list");
109
- for (const batch of pending.pending) {
235
+ async function dispatchPendingAutoAcks(state, maxInFlight) {
236
+ const work = await stateCommand(state, "list");
237
+ const inFlight = Number(work.inFlight);
238
+ if (!Number.isSafeInteger(inFlight) || inFlight < 0 || !Array.isArray(work.pending)) {
239
+ throw new DriverError("watch-state list returned invalid work capacity", EXIT_INGEST, work);
240
+ }
241
+ let available = Math.max(0, maxInFlight - inFlight);
242
+ for (const batch of work.pending) {
243
+ if (available === 0) break;
110
244
  await dispatchAutoAck(state, batch.pullRequest);
245
+ available -= 1;
111
246
  }
112
247
  }
113
248
 
114
- async function processFrame(state, frame, autoAck, pullRequests) {
249
+ async function processFrame(state, frame, autoAck, maxInFlight) {
115
250
  if (!frame || typeof frame !== "object" || typeof frame.type !== "string") {
116
251
  throw new DriverError("Invalid RFC 7464 frame", EXIT_SEQUENCE, frame);
117
252
  }
@@ -127,10 +262,10 @@ async function processFrame(state, frame, autoAck, pullRequests) {
127
262
  databaseId: frame.databaseId,
128
263
  anchoredAtCurrent: frame.anchoredAtCurrent,
129
264
  autoAck,
265
+ maxInFlight: autoAck ? maxInFlight : null,
130
266
  });
131
267
  } else if (frame.type === "comment-posted" && ingested.status === "queued") {
132
- if (autoAck) pullRequests.add(frame.event.pullRequestUrl);
133
- else {
268
+ if (!autoAck) {
134
269
  write({
135
270
  ok: true,
136
271
  type: "pending",
@@ -142,7 +277,16 @@ async function processFrame(state, frame, autoAck, pullRequests) {
142
277
  }
143
278
  }
144
279
 
145
- async function runWatchOnce(state, autoAck, stopping) {
280
+ function autoAckPollMilliseconds() {
281
+ const configured = Number(
282
+ process.env.RVW_WATCH_AUTO_ACK_POLL_MS ?? String(DEFAULT_AUTO_ACK_POLL_MS),
283
+ );
284
+ return Number.isSafeInteger(configured) && configured >= 10 && configured <= 60_000
285
+ ? configured
286
+ : DEFAULT_AUTO_ACK_POLL_MS;
287
+ }
288
+
289
+ async function runWatchOnce(state, autoAck, maxInFlight, stopping) {
146
290
  const current = await stateCommand(state, "status");
147
291
  const args = ["comment", "watch"];
148
292
  if (current.cursor) args.push("--after", current.cursor);
@@ -160,6 +304,25 @@ async function runWatchOnce(state, autoAck, stopping) {
160
304
  child.once("close", (code, signal) => resolve({ code, signal }));
161
305
  });
162
306
  child.stdin.end();
307
+ let autoAckPump = null;
308
+ let autoAckError = null;
309
+ const pumpAutoAcks = async () => {
310
+ if (!autoAck || stopping.requested) return;
311
+ if (autoAckPump) return autoAckPump;
312
+ autoAckPump = dispatchPendingAutoAcks(state, maxInFlight);
313
+ try {
314
+ await autoAckPump;
315
+ } catch (error) {
316
+ autoAckError ??= error;
317
+ child.kill("SIGTERM");
318
+ throw error;
319
+ } finally {
320
+ autoAckPump = null;
321
+ }
322
+ };
323
+ const autoAckTimer = autoAck
324
+ ? setInterval(() => void pumpAutoAcks().catch(() => undefined), autoAckPollMilliseconds())
325
+ : null;
163
326
  const decoder = new StringDecoder("utf8");
164
327
  let buffered = "";
165
328
  let readySeen = false;
@@ -169,7 +332,6 @@ async function runWatchOnce(state, autoAck, stopping) {
169
332
  buffered += decoder.write(chunk);
170
333
  const lines = buffered.split("\n");
171
334
  buffered = lines.pop() ?? "";
172
- const pullRequests = new Set();
173
335
  for (const rawLine of lines) {
174
336
  if (!rawLine) continue;
175
337
  if (!rawLine.startsWith("\u001e")) {
@@ -183,9 +345,9 @@ async function runWatchOnce(state, autoAck, stopping) {
183
345
  }
184
346
  readySeen ||= frame.type === "ready";
185
347
  stoppedSeen ||= frame.type === "stopped";
186
- await processFrame(state, frame, autoAck, pullRequests);
348
+ await processFrame(state, frame, autoAck, maxInFlight);
187
349
  }
188
- for (const pullRequest of pullRequests) await dispatchAutoAck(state, pullRequest);
350
+ await pumpAutoAcks();
189
351
  }
190
352
  buffered += decoder.end();
191
353
  if (buffered.trim()) {
@@ -196,6 +358,8 @@ async function runWatchOnce(state, autoAck, stopping) {
196
358
  );
197
359
  }
198
360
  const status = await statusPromise;
361
+ if (autoAckPump) await autoAckPump.catch(() => undefined);
362
+ if (autoAckError) throw autoAckError;
199
363
  stopping.child = null;
200
364
  return { ...status, stderr, readySeen, stoppedSeen };
201
365
  } catch (error) {
@@ -203,6 +367,8 @@ async function runWatchOnce(state, autoAck, stopping) {
203
367
  await statusPromise.catch(() => undefined);
204
368
  stopping.child = null;
205
369
  throw error;
370
+ } finally {
371
+ if (autoAckTimer) clearInterval(autoAckTimer);
206
372
  }
207
373
  }
208
374
 
@@ -217,42 +383,51 @@ function delay(milliseconds) {
217
383
  }
218
384
 
219
385
  async function main() {
220
- const { state, autoAck } = parseArguments(process.argv.slice(2));
221
- const stopping = { requested: false, child: null };
222
- const stop = () => {
223
- stopping.requested = true;
224
- stopping.child?.kill("SIGTERM");
225
- };
226
- process.once("SIGINT", stop);
227
- process.once("SIGTERM", stop);
228
- const maxRestarts = Number(process.env.RVW_WATCH_DRIVER_MAX_RESTARTS ?? "5");
229
- let restarts = 0;
386
+ const { state, autoAck, maxInFlight } = parseArguments(process.argv.slice(2));
387
+ const driverLock = acquireDriverLock(state);
230
388
  try {
231
- while (!stopping.requested) {
232
- const startedAt = Date.now();
233
- if (autoAck) await dispatchPendingAutoAcks(state);
234
- const result = await runWatchOnce(state, autoAck, stopping);
235
- if (stopping.requested) return;
236
- if (Date.now() - startedAt >= 30_000) restarts = 0;
237
- restarts += 1;
238
- if (restarts > maxRestarts) {
239
- throw new DriverError("rvw comment watch exceeded its reconnect limit", EXIT_WATCH, result);
389
+ const stopping = { requested: false, child: null };
390
+ const stop = () => {
391
+ stopping.requested = true;
392
+ stopping.child?.kill("SIGTERM");
393
+ };
394
+ process.once("SIGINT", stop);
395
+ process.once("SIGTERM", stop);
396
+ const maxRestarts = Number(process.env.RVW_WATCH_DRIVER_MAX_RESTARTS ?? "5");
397
+ let restarts = 0;
398
+ try {
399
+ while (!stopping.requested) {
400
+ const startedAt = Date.now();
401
+ if (autoAck) await dispatchPendingAutoAcks(state, maxInFlight);
402
+ const result = await runWatchOnce(state, autoAck, maxInFlight, stopping);
403
+ if (stopping.requested) return;
404
+ if (Date.now() - startedAt >= 30_000) restarts = 0;
405
+ restarts += 1;
406
+ if (restarts > maxRestarts) {
407
+ throw new DriverError(
408
+ "rvw comment watch exceeded its reconnect limit",
409
+ EXIT_WATCH,
410
+ result,
411
+ );
412
+ }
413
+ const delayMs = retryDelay(restarts);
414
+ write({
415
+ ok: true,
416
+ type: "reconnecting",
417
+ attempt: restarts,
418
+ delayMs,
419
+ lastExitCode: result.code,
420
+ lastSignal: result.signal,
421
+ readySeen: result.readySeen,
422
+ });
423
+ await delay(delayMs);
240
424
  }
241
- const delayMs = retryDelay(restarts);
242
- write({
243
- ok: true,
244
- type: "reconnecting",
245
- attempt: restarts,
246
- delayMs,
247
- lastExitCode: result.code,
248
- lastSignal: result.signal,
249
- readySeen: result.readySeen,
250
- });
251
- await delay(delayMs);
425
+ } finally {
426
+ process.off("SIGINT", stop);
427
+ process.off("SIGTERM", stop);
252
428
  }
253
429
  } finally {
254
- process.off("SIGINT", stop);
255
- process.off("SIGTERM", stop);
430
+ releaseDriverLock(driverLock);
256
431
  }
257
432
  }
258
433
 
@@ -68,7 +68,8 @@ function openState(statePath, create) {
68
68
  fail(`State database does not exist: ${absolute}`);
69
69
  }
70
70
  const database = new DatabaseSync(absolute);
71
- database.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
71
+ database.exec("PRAGMA busy_timeout = 5000;");
72
+ database.exec("PRAGMA journal_mode = WAL;");
72
73
  database.exec(`
73
74
  CREATE TABLE IF NOT EXISTS meta (
74
75
  key TEXT PRIMARY KEY,
@@ -98,17 +99,11 @@ function openState(statePath, create) {
98
99
  created_at TEXT NOT NULL,
99
100
  updated_at TEXT NOT NULL
100
101
  );
101
- CREATE TABLE IF NOT EXISTS comment_statuses (
102
- comment_ref TEXT PRIMARY KEY,
103
- idempotency_key TEXT NOT NULL UNIQUE,
104
- post_id TEXT UNIQUE,
105
- created_at TEXT NOT NULL,
106
- updated_at TEXT NOT NULL
107
- );
108
102
  CREATE TABLE IF NOT EXISTS operations (
109
103
  batch_id TEXT NOT NULL,
110
104
  comment_ref TEXT NOT NULL,
111
105
  idempotency_key TEXT NOT NULL UNIQUE,
106
+ post_id TEXT,
112
107
  PRIMARY KEY(batch_id, comment_ref)
113
108
  );
114
109
  CREATE TABLE IF NOT EXISTS suppressed_posts (
@@ -123,6 +118,7 @@ function openState(statePath, create) {
123
118
  CREATE INDEX IF NOT EXISTS batches_pending_pr
124
119
  ON batches(status, pull_request_url, next_attempt_at, created_at);
125
120
  `);
121
+ migrateStateSchema(database);
126
122
  if (create) chmodSync(absolute, 0o600);
127
123
  return { database, absolute };
128
124
  }
@@ -156,39 +152,102 @@ function setMeta(database, key, value) {
156
152
  .run(key, value);
157
153
  }
158
154
 
159
- function ensureCommentStatus(database, commentRef, now) {
160
- const existing = database
161
- .prepare("SELECT * FROM comment_statuses WHERE comment_ref = ?")
162
- .get(commentRef);
163
- if (existing) return existing;
164
- const legacyOperation = database
165
- .prepare(
166
- `SELECT o.idempotency_key
167
- FROM operations o
168
- JOIN batches b ON b.id = o.batch_id
169
- WHERE o.comment_ref = ? AND b.status IN ('pending', 'in_flight')
170
- ORDER BY b.created_at DESC LIMIT 1`,
171
- )
172
- .get(commentRef);
173
- const idempotencyKey =
174
- legacyOperation?.idempotency_key ?? `${getMeta(database, "task_id")}:status:${randomUUID()}`;
175
- database
176
- .prepare(
177
- `INSERT INTO comment_statuses(
178
- comment_ref, idempotency_key, post_id, created_at, updated_at
179
- ) VALUES (?, ?, NULL, ?, ?)`,
180
- )
181
- .run(commentRef, idempotencyKey, now, now);
182
- return database.prepare("SELECT * FROM comment_statuses WHERE comment_ref = ?").get(commentRef);
155
+ function tableExists(database, tableName) {
156
+ return Boolean(
157
+ database
158
+ .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?")
159
+ .get(tableName),
160
+ );
161
+ }
162
+
163
+ function migrateStateSchema(database) {
164
+ const operationColumns = database.prepare("PRAGMA table_info(operations)").all();
165
+ if (
166
+ operationColumns.some((column) => column.name === "post_id") &&
167
+ getMeta(database, "batch_scoped_status_posts") === "1"
168
+ ) {
169
+ return;
170
+ }
171
+ transaction(database, () => {
172
+ const currentOperationColumns = database.prepare("PRAGMA table_info(operations)").all();
173
+ if (!currentOperationColumns.some((column) => column.name === "post_id")) {
174
+ database.exec("ALTER TABLE operations ADD COLUMN post_id TEXT;");
175
+ }
176
+ if (getMeta(database, "batch_scoped_status_posts") === "1") return;
177
+ if (tableExists(database, "comment_statuses")) {
178
+ const rows = database
179
+ .prepare(
180
+ `SELECT b.id AS batch_id, e.comment_ref,
181
+ b.created_at AS batch_created_at,
182
+ o.idempotency_key AS operation_key, o.post_id AS operation_post_id,
183
+ s.idempotency_key AS legacy_key, s.post_id AS legacy_post_id,
184
+ s.updated_at AS legacy_updated_at
185
+ FROM batches b
186
+ JOIN (
187
+ SELECT DISTINCT batch_id, comment_ref FROM events WHERE batch_id IS NOT NULL
188
+ ) e ON e.batch_id = b.id
189
+ LEFT JOIN operations o ON o.batch_id = b.id AND o.comment_ref = e.comment_ref
190
+ LEFT JOIN comment_statuses s ON s.comment_ref = e.comment_ref
191
+ WHERE b.status != 'completed' AND b.attempts > 0
192
+ ORDER BY e.comment_ref,
193
+ CASE b.status WHEN 'in_flight' THEN 0 WHEN 'pending' THEN 1 ELSE 2 END,
194
+ b.created_at DESC`,
195
+ )
196
+ .all();
197
+ const migratedCommentRefs = new Set();
198
+ for (const row of rows) {
199
+ const useLegacyStatus =
200
+ !migratedCommentRefs.has(row.comment_ref) &&
201
+ row.legacy_key &&
202
+ row.legacy_updated_at >= row.batch_created_at;
203
+ if (useLegacyStatus) migratedCommentRefs.add(row.comment_ref);
204
+ if (row.operation_key) {
205
+ if (useLegacyStatus && row.operation_post_id === null && row.legacy_post_id !== null) {
206
+ database
207
+ .prepare("UPDATE operations SET post_id = ? WHERE batch_id = ? AND comment_ref = ?")
208
+ .run(row.legacy_post_id, row.batch_id, row.comment_ref);
209
+ }
210
+ continue;
211
+ }
212
+ database
213
+ .prepare(
214
+ `INSERT INTO operations(batch_id, comment_ref, idempotency_key, post_id)
215
+ VALUES (?, ?, ?, ?)`,
216
+ )
217
+ .run(
218
+ row.batch_id,
219
+ row.comment_ref,
220
+ useLegacyStatus
221
+ ? row.legacy_key
222
+ : `${getMeta(database, "task_id") ?? "task"}:${row.batch_id}:${randomUUID()}`,
223
+ useLegacyStatus ? row.legacy_post_id : null,
224
+ );
225
+ }
226
+ }
227
+ setMeta(database, "batch_scoped_status_posts", "1");
228
+ });
229
+ }
230
+
231
+ function ensureBatchOperations(database, batchId) {
232
+ const commentRefs = database
233
+ .prepare("SELECT DISTINCT comment_ref FROM events WHERE batch_id = ? ORDER BY comment_ref")
234
+ .all(batchId)
235
+ .map((operation) => operation.comment_ref);
236
+ for (let index = 0; index < commentRefs.length; index += 1) {
237
+ database
238
+ .prepare(
239
+ `INSERT OR IGNORE INTO operations(batch_id, comment_ref, idempotency_key, post_id)
240
+ VALUES (?, ?, ?, NULL)`,
241
+ )
242
+ .run(batchId, commentRefs[index], `${getMeta(database, "task_id")}:${batchId}:${index + 1}`);
243
+ }
183
244
  }
184
245
 
185
246
  function batchStatusOperations(database, batchId) {
186
247
  return database
187
248
  .prepare(
188
- `SELECT DISTINCT e.comment_ref, s.post_id
189
- FROM events e
190
- LEFT JOIN comment_statuses s ON s.comment_ref = e.comment_ref
191
- WHERE e.batch_id = ? ORDER BY e.comment_ref`,
249
+ `SELECT comment_ref, post_id FROM operations
250
+ WHERE batch_id = ? ORDER BY comment_ref`,
192
251
  )
193
252
  .all(batchId)
194
253
  .map((operation) => ({
@@ -311,19 +370,19 @@ function ingestEvent(database, frame) {
311
370
  now,
312
371
  );
313
372
  if (event.deleted) {
314
- const commentStatus = database
315
- .prepare("SELECT comment_ref FROM comment_statuses WHERE post_id = ?")
316
- .get(event.postId);
317
- if (commentStatus) {
373
+ const operations = database
374
+ .prepare("SELECT batch_id, comment_ref FROM operations WHERE post_id = ?")
375
+ .all(event.postId);
376
+ for (const operation of operations) {
318
377
  database
319
378
  .prepare(
320
- `UPDATE comment_statuses SET post_id = NULL, idempotency_key = ?, updated_at = ?
321
- WHERE comment_ref = ?`,
379
+ `UPDATE operations SET post_id = NULL, idempotency_key = ?
380
+ WHERE batch_id = ? AND comment_ref = ?`,
322
381
  )
323
382
  .run(
324
- `${getMeta(database, "task_id")}:status:${randomUUID()}`,
325
- now,
326
- commentStatus.comment_ref,
383
+ `${getMeta(database, "task_id")}:${operation.batch_id}:${randomUUID()}`,
384
+ operation.batch_id,
385
+ operation.comment_ref,
327
386
  );
328
387
  }
329
388
  }
@@ -397,6 +456,16 @@ function listPending(database) {
397
456
  });
398
457
  }
399
458
 
459
+ function listWork(database) {
460
+ const inFlight = database
461
+ .prepare("SELECT count(*) AS count FROM batches WHERE status = 'in_flight'")
462
+ .get();
463
+ return {
464
+ inFlight: Number(inFlight.count),
465
+ pending: listPending(database),
466
+ };
467
+ }
468
+
400
469
  async function waitForPending(database, options) {
401
470
  const intervalMs = positiveIntegerOption(options, "interval-ms", 250);
402
471
  const follow = options.follow === true;
@@ -448,8 +517,7 @@ function createBatch(database, pullRequestUrl, now) {
448
517
  "UPDATE events SET batch_id = ?, updated_at = ? WHERE pull_request_url = ? AND status = 'pending' AND batch_id IS NULL",
449
518
  )
450
519
  .run(batchId, now, pullRequestUrl);
451
- const commentRefs = [...new Set(events.map((event) => event.comment_ref))];
452
- for (const commentRef of commentRefs) ensureCommentStatus(database, commentRef, now);
520
+ ensureBatchOperations(database, batchId);
453
521
  return batchId;
454
522
  }
455
523
 
@@ -497,17 +565,11 @@ function claim(database, pullRequestUrl, writeKey) {
497
565
  commentRef: event.comment_ref,
498
566
  pullRequestUrl: event.pull_request_url,
499
567
  }));
500
- const commentRefs = database
501
- .prepare("SELECT DISTINCT comment_ref FROM events WHERE batch_id = ? ORDER BY comment_ref")
502
- .all(batchId)
503
- .map((operation) => operation.comment_ref);
504
- for (const commentRef of commentRefs) ensureCommentStatus(database, commentRef, now);
568
+ ensureBatchOperations(database, batchId);
505
569
  const operations = database
506
570
  .prepare(
507
- `SELECT DISTINCT e.comment_ref, s.idempotency_key, s.post_id
508
- FROM events e
509
- JOIN comment_statuses s ON s.comment_ref = e.comment_ref
510
- WHERE e.batch_id = ? ORDER BY e.comment_ref`,
571
+ `SELECT comment_ref, idempotency_key, post_id FROM operations
572
+ WHERE batch_id = ? ORDER BY comment_ref`,
511
573
  )
512
574
  .all(batchId)
513
575
  .map((operation) => ({
@@ -568,17 +630,16 @@ function acknowledge(database, leaseId, input) {
568
630
  .get(leaseId);
569
631
  if (!batch) fail("Active lease was not found");
570
632
  const operation = database
571
- .prepare("SELECT 1 AS present FROM events WHERE batch_id = ? AND comment_ref = ? LIMIT 1")
633
+ .prepare("SELECT post_id FROM operations WHERE batch_id = ? AND comment_ref = ?")
572
634
  .get(batch.id, input.commentRef);
573
635
  if (!operation) fail("Comment is not part of the active lease");
574
636
  const now = new Date().toISOString();
575
- const status = ensureCommentStatus(database, input.commentRef, now);
576
- if (status.post_id !== null && status.post_id !== input.postId) {
577
- fail("Comment already has another status post");
637
+ if (operation.post_id !== null && operation.post_id !== input.postId) {
638
+ fail("Batch operation already has another status post");
578
639
  }
579
640
  database
580
- .prepare("UPDATE comment_statuses SET post_id = ?, updated_at = ? WHERE comment_ref = ?")
581
- .run(input.postId, now, input.commentRef);
641
+ .prepare("UPDATE operations SET post_id = ? WHERE batch_id = ? AND comment_ref = ?")
642
+ .run(input.postId, batch.id, input.commentRef);
582
643
  database
583
644
  .prepare("INSERT OR IGNORE INTO suppressed_posts(post_id, created_at) VALUES (?, ?)")
584
645
  .run(input.postId, now);
@@ -591,7 +652,7 @@ function acknowledge(database, leaseId, input) {
591
652
  batchId: batch.id,
592
653
  commentRef: input.commentRef,
593
654
  statusPostId: input.postId,
594
- status: status.post_id === null ? "recorded" : "existing",
655
+ status: operation.post_id === null ? "recorded" : "existing",
595
656
  };
596
657
  });
597
658
  }
@@ -772,7 +833,7 @@ async function main() {
772
833
  return;
773
834
  }
774
835
  if (command === "list") {
775
- write({ ok: true, pending: listPending(database) });
836
+ write({ ok: true, ...listWork(database) });
776
837
  return;
777
838
  }
778
839
  if (command === "wait") {