rivet-design 0.11.11 → 0.11.13

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 (105) hide show
  1. package/dist/install/harnesses.d.ts.map +1 -1
  2. package/dist/install/harnesses.js +122 -4
  3. package/dist/install/harnesses.js.map +1 -1
  4. package/dist/mcp/agent-variants/SessionStore.d.ts +150 -10
  5. package/dist/mcp/agent-variants/SessionStore.d.ts.map +1 -1
  6. package/dist/mcp/agent-variants/SessionStore.js +468 -23
  7. package/dist/mcp/agent-variants/SessionStore.js.map +1 -1
  8. package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts +143 -51
  9. package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts.map +1 -1
  10. package/dist/mcp/agent-variants/WorktreeOrchestrator.js +465 -299
  11. package/dist/mcp/agent-variants/WorktreeOrchestrator.js.map +1 -1
  12. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.d.ts +5 -4
  13. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.d.ts.map +1 -1
  14. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.js +24 -5
  15. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.js.map +1 -1
  16. package/dist/mcp/agent-variants/contracts.d.ts +102 -89
  17. package/dist/mcp/agent-variants/contracts.d.ts.map +1 -1
  18. package/dist/mcp/agent-variants/contracts.js +29 -14
  19. package/dist/mcp/agent-variants/contracts.js.map +1 -1
  20. package/dist/mcp/agent-variants/index.d.ts +3 -3
  21. package/dist/mcp/agent-variants/index.d.ts.map +1 -1
  22. package/dist/mcp/agent-variants/index.js +1 -2
  23. package/dist/mcp/agent-variants/index.js.map +1 -1
  24. package/dist/mcp/agent-variants/previewQa.d.ts.map +1 -1
  25. package/dist/mcp/agent-variants/previewQa.js +0 -6
  26. package/dist/mcp/agent-variants/previewQa.js.map +1 -1
  27. package/dist/mcp/agent-variants/tools.d.ts +12 -4
  28. package/dist/mcp/agent-variants/tools.d.ts.map +1 -1
  29. package/dist/mcp/agent-variants/tools.js +69 -13
  30. package/dist/mcp/agent-variants/tools.js.map +1 -1
  31. package/dist/mcp/changeBatchClassification.d.ts +30 -0
  32. package/dist/mcp/changeBatchClassification.d.ts.map +1 -1
  33. package/dist/mcp/changeBatchClassification.js +76 -0
  34. package/dist/mcp/changeBatchClassification.js.map +1 -1
  35. package/dist/mcp/instructions.d.ts.map +1 -1
  36. package/dist/mcp/instructions.js +7 -1
  37. package/dist/mcp/instructions.js.map +1 -1
  38. package/dist/mcp/server.d.ts +51 -3
  39. package/dist/mcp/server.d.ts.map +1 -1
  40. package/dist/mcp/server.js +404 -156
  41. package/dist/mcp/server.js.map +1 -1
  42. package/dist/mcp/watchLoopResumePrompt.d.ts +16 -0
  43. package/dist/mcp/watchLoopResumePrompt.d.ts.map +1 -0
  44. package/dist/mcp/watchLoopResumePrompt.js +21 -0
  45. package/dist/mcp/watchLoopResumePrompt.js.map +1 -0
  46. package/dist/routes/agentVariants.d.ts +10 -1
  47. package/dist/routes/agentVariants.d.ts.map +1 -1
  48. package/dist/routes/agentVariants.js +214 -3
  49. package/dist/routes/agentVariants.js.map +1 -1
  50. package/dist/routes/mcp.d.ts.map +1 -1
  51. package/dist/routes/mcp.js +106 -47
  52. package/dist/routes/mcp.js.map +1 -1
  53. package/dist/services/BridgeBatchPipeline.d.ts +99 -0
  54. package/dist/services/BridgeBatchPipeline.d.ts.map +1 -0
  55. package/dist/services/BridgeBatchPipeline.js +278 -0
  56. package/dist/services/BridgeBatchPipeline.js.map +1 -0
  57. package/dist/services/BridgeQueueStore.d.ts +28 -0
  58. package/dist/services/BridgeQueueStore.d.ts.map +1 -0
  59. package/dist/services/BridgeQueueStore.js +88 -0
  60. package/dist/services/BridgeQueueStore.js.map +1 -0
  61. package/dist/services/InlineVariantGenerationService.d.ts.map +1 -1
  62. package/dist/services/InlineVariantGenerationService.js +15 -3
  63. package/dist/services/InlineVariantGenerationService.js.map +1 -1
  64. package/dist/services/SessionBridgeService.d.ts +127 -31
  65. package/dist/services/SessionBridgeService.d.ts.map +1 -1
  66. package/dist/services/SessionBridgeService.js +428 -83
  67. package/dist/services/SessionBridgeService.js.map +1 -1
  68. package/dist/services/TelemetryService.d.ts +30 -0
  69. package/dist/services/TelemetryService.d.ts.map +1 -1
  70. package/dist/services/TelemetryService.js +42 -0
  71. package/dist/services/TelemetryService.js.map +1 -1
  72. package/dist/services/WorktreeManager.d.ts +29 -2
  73. package/dist/services/WorktreeManager.d.ts.map +1 -1
  74. package/dist/services/WorktreeManager.js +73 -7
  75. package/dist/services/WorktreeManager.js.map +1 -1
  76. package/dist/services/createAgentVariantsOrchestrator.d.ts +0 -8
  77. package/dist/services/createAgentVariantsOrchestrator.d.ts.map +1 -1
  78. package/dist/services/createAgentVariantsOrchestrator.js +1 -18
  79. package/dist/services/createAgentVariantsOrchestrator.js.map +1 -1
  80. package/dist/utils/portUtils.d.ts +9 -0
  81. package/dist/utils/portUtils.d.ts.map +1 -1
  82. package/dist/utils/portUtils.js +26 -0
  83. package/dist/utils/portUtils.js.map +1 -1
  84. package/dist/utils/skills/claude-skill.d.ts +1 -1
  85. package/dist/utils/skills/claude-skill.d.ts.map +1 -1
  86. package/dist/utils/skills/claude-skill.js +31 -12
  87. package/dist/utils/skills/claude-skill.js.map +1 -1
  88. package/dist/utils/skills/cursor-rules.d.ts +1 -1
  89. package/dist/utils/skills/cursor-rules.d.ts.map +1 -1
  90. package/dist/utils/skills/cursor-rules.js +26 -11
  91. package/dist/utils/skills/cursor-rules.js.map +1 -1
  92. package/dist/utils/skills/shared-variants-protocol.d.ts +28 -0
  93. package/dist/utils/skills/shared-variants-protocol.d.ts.map +1 -1
  94. package/dist/utils/skills/shared-variants-protocol.js +88 -14
  95. package/dist/utils/skills/shared-variants-protocol.js.map +1 -1
  96. package/package.json +3 -2
  97. package/src/ui/dist/assets/main-DREt3GGU.css +1 -0
  98. package/src/ui/dist/assets/main-Dt7ShmjM.js +655 -0
  99. package/src/ui/dist/index.html +2 -2
  100. package/dist/mcp/agent-variants/designCritique.d.ts +0 -167
  101. package/dist/mcp/agent-variants/designCritique.d.ts.map +0 -1
  102. package/dist/mcp/agent-variants/designCritique.js +0 -774
  103. package/dist/mcp/agent-variants/designCritique.js.map +0 -1
  104. package/src/ui/dist/assets/main-B1LAuLK_.js +0 -655
  105. package/src/ui/dist/assets/main-D-YwdYjm.css +0 -1
@@ -1,9 +1,37 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SessionBridgeService = void 0;
3
+ exports.SessionBridgeService = exports.MAX_APPLY_BATCH_BYTES = exports.MAX_VARIANT_REQUEST_BYTES = exports.MAX_LEASED_VARIANT_BATCHES = exports.MAX_PENDING_APPLY_BATCHES = exports.MAX_PENDING_VARIANT_REQUESTS = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const index_core_1 = require("../index-core");
6
+ const changeBatchClassification_1 = require("../mcp/changeBatchClassification");
7
+ const BridgeBatchPipeline_1 = require("./BridgeBatchPipeline");
8
+ const BridgeQueueStore_1 = require("./BridgeQueueStore");
6
9
  const log = (0, index_core_1.createLogger)('SessionBridgeService');
10
+ /**
11
+ * Upper bound on the variant-request FIFO depth. The queue intentionally
12
+ * appends (never clobbers) so concurrent "Vary" runs aren't dropped, but
13
+ * without a cap a caller hitting the unauthenticated queue endpoint could
14
+ * append unboundedly and exhaust server memory. 16 is far above any real
15
+ * concurrent-Vary workload; past it we reject with backpressure (HTTP 429)
16
+ * so the request surfaces as an error the user can retry, rather than being
17
+ * silently dropped or growing the queue.
18
+ */
19
+ exports.MAX_PENDING_VARIANT_REQUESTS = 16;
20
+ /** FIFO depth cap for apply batches (was a single overwrite slot). */
21
+ exports.MAX_PENDING_APPLY_BATCHES = 16;
22
+ /** Maximum concurrent variant batches leased to agents/watchers. */
23
+ exports.MAX_LEASED_VARIANT_BATCHES = 4;
24
+ /**
25
+ * Per-batch byte budget for a queued variant-request/refine batch. The count
26
+ * cap above bounds HOW MANY batches queue; this bounds how BIG one can be. The
27
+ * route schema already caps attached images, but `instruction` / `sourceFiles`
28
+ * / element metadata are otherwise bounded only by the (much larger) HTTP body
29
+ * limit — so without this a local/LAN caller could pin memory with a few large
30
+ * batches × the FIFO depth. ~2MB of non-image text/metadata is generous.
31
+ */
32
+ exports.MAX_VARIANT_REQUEST_BYTES = 2 * 1024 * 1024;
33
+ /** Same per-batch byte cap for apply batches queued on the apply FIFO. */
34
+ exports.MAX_APPLY_BATCH_BYTES = exports.MAX_VARIANT_REQUEST_BYTES;
7
35
  /**
8
36
  * Manages the lifecycle of an MCP visual editing session and queues
9
37
  * changes between the Rivet UI and the MCP server.
@@ -18,12 +46,35 @@ class SessionBridgeService {
18
46
  _isVariantPortAllowed = null;
19
47
  statusAccessToken = null;
20
48
  projectPath = null;
21
- pendingChanges = null;
49
+ applyPipeline = new BridgeBatchPipeline_1.BridgeBatchPipeline({
50
+ kind: 'apply',
51
+ maxQueued: exports.MAX_PENDING_APPLY_BATCHES,
52
+ maxConcurrentLeases: 1,
53
+ queueFullReason: 'apply_queue_full',
54
+ });
55
+ variantPipeline = new BridgeBatchPipeline_1.BridgeBatchPipeline({
56
+ kind: 'variant',
57
+ maxQueued: exports.MAX_PENDING_VARIANT_REQUESTS,
58
+ maxConcurrentLeases: exports.MAX_LEASED_VARIANT_BATCHES,
59
+ queueFullReason: 'variant_queue_full',
60
+ });
22
61
  telemetryContext = null;
23
62
  roundsCompleted = 0;
24
63
  feedback = [];
25
64
  status = 'IDLE';
26
- waitResolve = null;
65
+ // Blocking watch_for_changes waiters, FIFO. A multi-element queue (not a
66
+ // single slot) so several concurrent watchers can each block and each wake on
67
+ // a DIFFERENT queued change — one change wakes one waiter — letting parallel
68
+ // agents pick up distinct batches instead of clobbering each other's waiter.
69
+ waitResolvers = [];
70
+ /**
71
+ * Subscribers notified whenever {@link WatcherLiveness} could have changed
72
+ * (queue/drain/waiter register/settle). The variants SSE stream subscribes so
73
+ * a strand the user causes — sending a change with no watcher draining it —
74
+ * re-pushes the snapshot promptly instead of waiting on the next orchestrator
75
+ * event, which a stranded queue never produces.
76
+ */
77
+ livenessListeners = new Set();
27
78
  activeRunCorrelation = null;
28
79
  baseDevServerHealth = null;
29
80
  devServerHealth = {
@@ -46,9 +97,28 @@ class SessionBridgeService {
46
97
  }
47
98
  this.sessionId = (0, crypto_1.randomUUID)();
48
99
  this.projectPath = projectPath;
49
- this.pendingChanges = null;
100
+ this.applyPipeline = new BridgeBatchPipeline_1.BridgeBatchPipeline({
101
+ kind: 'apply',
102
+ maxQueued: exports.MAX_PENDING_APPLY_BATCHES,
103
+ maxConcurrentLeases: 1,
104
+ queueFullReason: 'apply_queue_full',
105
+ });
106
+ this.variantPipeline = new BridgeBatchPipeline_1.BridgeBatchPipeline({
107
+ kind: 'variant',
108
+ maxQueued: exports.MAX_PENDING_VARIANT_REQUESTS,
109
+ maxConcurrentLeases: exports.MAX_LEASED_VARIANT_BATCHES,
110
+ queueFullReason: 'variant_queue_full',
111
+ });
112
+ const persisted = BridgeQueueStore_1.bridgeQueueStore.load(projectPath, this.sessionId);
113
+ if (persisted) {
114
+ this.applyPipeline.hydrate(persisted.apply);
115
+ this.variantPipeline.hydrate(persisted.variant);
116
+ this.roundsCompleted = persisted.roundsCompleted;
117
+ }
118
+ else {
119
+ this.roundsCompleted = 0;
120
+ }
50
121
  this.telemetryContext = null;
51
- this.roundsCompleted = 0;
52
122
  this.feedback = [];
53
123
  this.status = 'IDLE';
54
124
  this.activeRunCorrelation = null;
@@ -66,8 +136,32 @@ class SessionBridgeService {
66
136
  startedByRivet: false,
67
137
  };
68
138
  log.info(`Session started: ${this.sessionId} for ${projectPath}`);
139
+ this.persistQueue();
140
+ this.notifyLivenessChange();
69
141
  return this.sessionId;
70
142
  }
143
+ /** Write the current queue snapshot to disk for MCP restart recovery. */
144
+ persistQueue() {
145
+ if (!this.sessionId || !this.projectPath) {
146
+ return;
147
+ }
148
+ BridgeQueueStore_1.bridgeQueueStore.save(this.projectPath, this.sessionId, {
149
+ ...(0, BridgeQueueStore_1.emptyBridgeQueueSnapshot)(this.projectPath),
150
+ apply: this.applyPipeline.snapshot(),
151
+ variant: this.variantPipeline.snapshot(),
152
+ roundsCompleted: this.roundsCompleted,
153
+ updatedAt: Date.now(),
154
+ });
155
+ }
156
+ buildQueuedBatch(intent) {
157
+ const requestId = intent.agentRunId ?? intent.traceId ?? (0, crypto_1.randomUUID)();
158
+ return {
159
+ intent,
160
+ enqueuedAt: Date.now(),
161
+ leaseId: (0, crypto_1.randomUUID)(),
162
+ requestId,
163
+ };
164
+ }
71
165
  /**
72
166
  * Add a feedback message to the session.
73
167
  * Feedback is shown in the Rivet UI to let the user know what the MCP client is doing.
@@ -127,30 +221,162 @@ class SessionBridgeService {
127
221
  }
128
222
  /**
129
223
  * Queue changes from the Rivet UI for retrieval by the MCP client.
130
- * Replaces any previously queued intent that hasn't been retrieved yet.
224
+ * Each send appends to the apply or variant FIFO (never overwrites).
131
225
  */
132
226
  queueChangeRequest(intent) {
133
227
  if (!this.sessionId) {
134
228
  log.warn('Cannot queue changes: no active session');
135
- return;
229
+ return { accepted: true };
136
230
  }
137
- // Clear feedback from previous round when a new round starts
138
231
  this.feedback = [];
139
- this.pendingChanges = intent;
140
232
  this.updateTelemetryContext(intent.telemetryContext);
141
- log.info(`Change request queued (Round ${this.roundsCompleted + 1}): ${intent.changes.length} changes, ${intent.sourceFiles.length} source files`);
142
- // Wake up any blocking watch_for_changes call
143
- if (this.waitResolve) {
144
- this.waitResolve(intent);
145
- this.waitResolve = null;
233
+ let enqueueResult;
234
+ if (!(0, changeBatchClassification_1.isMixedVariantBatch)(intent) &&
235
+ ((0, changeBatchClassification_1.isSingleVariantRequestBatch)(intent) || (0, changeBatchClassification_1.isStaticPreviewRefineBatch)(intent))) {
236
+ const approxBytes = Buffer.byteLength(JSON.stringify(intent));
237
+ if (approxBytes > exports.MAX_VARIANT_REQUEST_BYTES) {
238
+ log.warn(`Variant-request batch too large (${approxBytes} bytes > ${exports.MAX_VARIANT_REQUEST_BYTES}); rejecting`);
239
+ return { accepted: false, reason: 'variant_request_too_large' };
240
+ }
241
+ enqueueResult = this.variantPipeline.enqueue(this.buildQueuedBatch(intent));
242
+ if (enqueueResult.accepted) {
243
+ log.info(`Variant-request batch queued (${this.variantPipeline.queuedDepth()} pending): ${intent.changes.length} changes`);
244
+ }
245
+ else {
246
+ log.warn(`Variant-request queue full (${exports.MAX_PENDING_VARIANT_REQUESTS} pending); rejecting new batch`);
247
+ }
248
+ }
249
+ else {
250
+ const approxBytes = Buffer.byteLength(JSON.stringify(intent));
251
+ if (approxBytes > exports.MAX_APPLY_BATCH_BYTES) {
252
+ log.warn(`Apply batch too large (${approxBytes} bytes > ${exports.MAX_APPLY_BATCH_BYTES}); rejecting`);
253
+ return { accepted: false, reason: 'apply_batch_too_large' };
254
+ }
255
+ enqueueResult = this.applyPipeline.enqueue(this.buildQueuedBatch(intent));
256
+ if (enqueueResult.accepted) {
257
+ log.info(`Change request queued (Round ${this.roundsCompleted + 1}): ${intent.changes.length} changes, ${intent.sourceFiles.length} source files`);
258
+ }
259
+ else {
260
+ log.warn(`Apply queue full (${exports.MAX_PENDING_APPLY_BATCHES} pending); rejecting new batch`);
261
+ }
146
262
  }
263
+ if (!enqueueResult.accepted) {
264
+ return enqueueResult;
265
+ }
266
+ this.persistQueue();
267
+ const waiter = this.waitResolvers.shift();
268
+ if (waiter)
269
+ waiter(intent);
270
+ this.notifyLivenessChange();
271
+ return { accepted: true };
147
272
  }
148
273
  /**
149
- * Retrieve pending changes, or null if none is queued.
150
- * Does NOT clear the queue — call clearPending() after processing.
274
+ * Retrieve the oldest queued apply batch intent without leasing it.
151
275
  */
152
276
  getPendingChanges() {
153
- return this.pendingChanges;
277
+ return this.applyPipeline.peekHeadIntent();
278
+ }
279
+ /** True when one or more variant-request/refine batches are queued or leased. */
280
+ hasPendingVariantRequests() {
281
+ return this.variantPipeline.hasWork();
282
+ }
283
+ /** Queued variant batches still waiting for lease pickup. */
284
+ pendingVariantRequestCount() {
285
+ return this.variantPipeline.queuedDepth();
286
+ }
287
+ /** Non-destructive snapshot of queued variant-request/refine batches. */
288
+ peekVariantRequests() {
289
+ return this.variantPipeline.peekQueuedIntents();
290
+ }
291
+ /**
292
+ * Lease the oldest eligible batch across apply and variant channels.
293
+ * Global FIFO uses oldest `enqueuedAt` among channels that can lease.
294
+ */
295
+ leaseBatch(requestId) {
296
+ if (requestId) {
297
+ const applyLease = this.applyPipeline.leaseNext(requestId);
298
+ if (applyLease) {
299
+ this.persistQueue();
300
+ this.notifyLivenessChange();
301
+ return applyLease;
302
+ }
303
+ const variantLease = this.variantPipeline.leaseNext(requestId);
304
+ if (variantLease) {
305
+ this.persistQueue();
306
+ this.notifyLivenessChange();
307
+ return variantLease;
308
+ }
309
+ return null;
310
+ }
311
+ const candidates = [];
312
+ const applyHeadAt = this.applyPipeline.oldestQueuedEnqueuedAt();
313
+ if (this.applyPipeline.canLease() && applyHeadAt !== null) {
314
+ candidates.push({ kind: 'apply', enqueuedAt: applyHeadAt });
315
+ }
316
+ const variantHeadAt = this.variantPipeline.oldestQueuedEnqueuedAt();
317
+ if (this.variantPipeline.canLease() && variantHeadAt !== null) {
318
+ candidates.push({ kind: 'variant', enqueuedAt: variantHeadAt });
319
+ }
320
+ if (candidates.length === 0) {
321
+ return null;
322
+ }
323
+ candidates.sort((a, b) => a.enqueuedAt - b.enqueuedAt);
324
+ const head = candidates[0];
325
+ const lease = head.kind === 'apply'
326
+ ? this.applyPipeline.leaseNext()
327
+ : this.variantPipeline.leaseNext();
328
+ if (lease) {
329
+ this.persistQueue();
330
+ this.notifyLivenessChange();
331
+ }
332
+ return lease;
333
+ }
334
+ /**
335
+ * Release a leased batch after the agent completes work (`refresh_git` or `ack_batch`).
336
+ */
337
+ ackBatch(params) {
338
+ if (this.applyPipeline.findLease(params.leaseId)) {
339
+ const hadLease = Boolean(this.applyPipeline.findLease(params.leaseId));
340
+ const result = this.applyPipeline.ack(params);
341
+ if (hadLease) {
342
+ this.roundsCompleted += 1;
343
+ }
344
+ this.persistQueue();
345
+ this.notifyLivenessChange();
346
+ const applyAck = { ...result, kind: 'apply' };
347
+ return applyAck;
348
+ }
349
+ if (this.variantPipeline.findLease(params.leaseId)) {
350
+ const result = this.variantPipeline.ack(params);
351
+ this.persistQueue();
352
+ this.notifyLivenessChange();
353
+ const variantAck = { ...result, kind: 'variant' };
354
+ return variantAck;
355
+ }
356
+ return { acked: true, mergedRequestIds: [] };
357
+ }
358
+ /** True when either channel has queued or in-flight leased work. */
359
+ hasUnfinishedWork() {
360
+ return this.applyPipeline.hasWork() || this.variantPipeline.hasWork();
361
+ }
362
+ /** Queue and leased depth for one channel (status API). */
363
+ getChannelDepth(kind) {
364
+ const pipeline = kind === 'apply' ? this.applyPipeline : this.variantPipeline;
365
+ return {
366
+ queued: pipeline.queuedDepth(),
367
+ leased: pipeline.leasedDepth(),
368
+ };
369
+ }
370
+ /** @deprecated Use {@link ackBatch} after the agent completes a leased apply batch. */
371
+ shiftVariantRequest() {
372
+ const lease = this.variantPipeline.leaseNext();
373
+ if (!lease) {
374
+ return null;
375
+ }
376
+ this.variantPipeline.ack({ leaseId: lease.leaseId, requestId: lease.requestId });
377
+ this.persistQueue();
378
+ this.notifyLivenessChange();
379
+ return lease.intent;
154
380
  }
155
381
  /**
156
382
  * Block until changes are queued, or return immediately if already pending.
@@ -158,89 +384,115 @@ class SessionBridgeService {
158
384
  * Rejects if the timeout is reached.
159
385
  */
160
386
  waitForChanges(timeoutMs = 300_000) {
161
- if (this.pendingChanges) {
162
- return Promise.resolve(this.pendingChanges);
387
+ const existing = this.applyPipeline.peekHeadIntent() ??
388
+ this.variantPipeline.peekHeadIntent();
389
+ if (existing) {
390
+ return Promise.resolve(existing);
163
391
  }
164
392
  return new Promise((resolve, reject) => {
165
- const timer = setTimeout(() => {
166
- this.waitResolve = null;
167
- reject(new Error('Timed out waiting for design changes'));
168
- }, timeoutMs);
169
- this.waitResolve = (intent) => {
393
+ const resolver = (intent) => {
170
394
  clearTimeout(timer);
171
395
  resolve(intent);
172
396
  };
397
+ const timer = setTimeout(() => {
398
+ const idx = this.waitResolvers.indexOf(resolver);
399
+ if (idx !== -1)
400
+ this.waitResolvers.splice(idx, 1);
401
+ this.notifyLivenessChange();
402
+ reject(new Error('Timed out waiting for design changes'));
403
+ }, timeoutMs);
404
+ this.waitResolvers.push(resolver);
405
+ this.notifyLivenessChange();
173
406
  });
174
407
  }
175
408
  /**
176
- * Clear the pending changes queue and increment the round counter.
177
- * Call after the MCP client has retrieved and processed the intent.
409
+ * Ack the active apply lease after processing (legacy alias for tests).
178
410
  */
179
411
  clearPending(expectedIntent) {
180
- if (expectedIntent && this.pendingChanges !== expectedIntent) {
412
+ const snapshot = this.applyPipeline.snapshot();
413
+ const activeLease = snapshot.leased[0];
414
+ if (activeLease) {
415
+ if (expectedIntent && activeLease.intent !== expectedIntent) {
416
+ log.info('Pending intent changed while processing; preserving newer intent');
417
+ return;
418
+ }
419
+ this.ackBatch({
420
+ leaseId: activeLease.leaseId,
421
+ requestId: activeLease.requestId,
422
+ });
423
+ log.info(`Pending intent cleared, round ${this.roundsCompleted} complete`);
424
+ return;
425
+ }
426
+ const head = this.applyPipeline.peekHeadIntent();
427
+ if (!head) {
428
+ return;
429
+ }
430
+ if (expectedIntent && head !== expectedIntent) {
181
431
  log.info('Pending intent changed while processing; preserving newer intent');
182
432
  return;
183
433
  }
184
- if (this.pendingChanges) {
185
- this.roundsCompleted++;
186
- this.pendingChanges = null;
434
+ const lease = this.applyPipeline.leaseNext();
435
+ if (lease) {
436
+ this.ackBatch({ leaseId: lease.leaseId, requestId: lease.requestId });
187
437
  log.info(`Pending intent cleared, round ${this.roundsCompleted} complete`);
188
438
  }
189
439
  }
190
- /**
191
- * Consume a single-item variant-request batch from the pending queue.
192
- *
193
- * Variant-request batches are not "applied" — they are routed into the MCP
194
- * variants lifecycle (propose_variants → approval → continue → commit). They
195
- * must NOT call startApplyingRun, must NOT fire trackChangesQueued /
196
- * trackAgentRunStarted, and must NOT increment the apply-run round counter.
197
- *
198
- * Returns the consumed intent so callers can derive the agent's
199
- * propose_variants target from it.
200
- */
440
+ /** @deprecated Leased batches are released via {@link ackBatch}. */
201
441
  consumeVariantRequestBatch(expectedIntent) {
202
- const intent = this.pendingChanges;
203
- if (!intent)
204
- return null;
205
- if (expectedIntent && intent !== expectedIntent) {
206
- log.info('Pending intent changed while consuming variant request; preserving newer intent');
442
+ const lease = expectedIntent
443
+ ? this.findVariantLeaseForIntent(expectedIntent)
444
+ : this.variantPipeline.leaseNext();
445
+ if (!lease) {
207
446
  return null;
208
447
  }
209
- this.pendingChanges = null;
210
- // Wake any blocking watch_for_changes caller that hasn't returned yet,
211
- // mirroring the queue/wake symmetry of queueChangeRequest. (Defensive:
212
- // the consume path is normally reached from a watch_for_changes caller
213
- // that has already resolved; this is a no-op in that case.)
214
- this.waitResolve = null;
215
- log.info(`Variant-request batch consumed (no apply run started, ${intent.changes.length} items)`);
216
- return intent;
448
+ log.info(`variant-request batch consumed (no apply run started, ${lease.intent.changes.length} items)`);
449
+ return lease.intent;
217
450
  }
218
- /**
219
- * Consume a static-preview refinement batch from the pending queue.
220
- *
221
- * These batches refine an in-memory HTML variant through the variants MCP
222
- * lifecycle. They must not start a base project apply run or increment the
223
- * apply-round counter.
224
- */
451
+ /** @deprecated Leased batches are released via {@link ackBatch}. */
225
452
  consumeStaticPreviewRefineBatch(expectedIntent) {
226
- const intent = this.pendingChanges;
227
- if (!intent)
228
- return null;
229
- if (expectedIntent && intent !== expectedIntent) {
230
- log.info('Pending intent changed while consuming static-preview refine request; preserving newer intent');
453
+ const lease = expectedIntent
454
+ ? this.findVariantLeaseForIntent(expectedIntent)
455
+ : this.variantPipeline.leaseNext();
456
+ if (!lease) {
231
457
  return null;
232
458
  }
233
- this.pendingChanges = null;
234
- this.waitResolve = null;
235
- log.info(`Static-preview refine batch consumed (no apply run started, ${intent.changes.length} items)`);
236
- return intent;
459
+ log.info(`static-preview refine batch consumed (no apply run started, ${lease.intent.changes.length} items)`);
460
+ return lease.intent;
461
+ }
462
+ findVariantLeaseForIntent(expectedIntent) {
463
+ const requestId = expectedIntent.agentRunId ?? expectedIntent.traceId;
464
+ if (requestId) {
465
+ const existing = this.variantPipeline.leaseNext(requestId);
466
+ if (existing) {
467
+ return existing;
468
+ }
469
+ const leased = this.variantPipeline.leaseQueuedRequestId(requestId);
470
+ if (leased) {
471
+ this.persistQueue();
472
+ this.notifyLivenessChange();
473
+ return leased;
474
+ }
475
+ }
476
+ else {
477
+ const head = this.variantPipeline.peekHeadIntent();
478
+ if (head === expectedIntent) {
479
+ const leased = this.variantPipeline.leaseNext();
480
+ if (leased) {
481
+ this.persistQueue();
482
+ this.notifyLivenessChange();
483
+ return leased;
484
+ }
485
+ }
486
+ }
487
+ log.info('Pending variant batch no longer queued (already consumed/superseded)');
488
+ return null;
237
489
  }
238
490
  /**
239
491
  * Remove variant-request comment items from a mixed pending batch while
240
492
  * preserving all non-variant items for a follow-up apply run.
241
493
  */
242
494
  rejectMixedVariantRequestBatch(expectedIntent) {
243
- const intent = this.pendingChanges;
495
+ const intent = this.applyPipeline.peekHeadIntent();
244
496
  if (!intent)
245
497
  return null;
246
498
  if (expectedIntent && intent !== expectedIntent) {
@@ -253,17 +505,21 @@ class SessionBridgeService {
253
505
  return intent;
254
506
  }
255
507
  if (nextChanges.length === 0) {
256
- this.pendingChanges = null;
257
- this.waitResolve = null;
508
+ this.applyPipeline.replaceHeadQueuedBatch(null);
258
509
  log.info('Mixed variant batch rejection removed all pending changes');
510
+ this.persistQueue();
511
+ this.notifyLivenessChange();
259
512
  return null;
260
513
  }
261
- this.pendingChanges = {
514
+ const preserved = {
262
515
  ...intent,
263
516
  changes: nextChanges,
264
517
  };
518
+ this.applyPipeline.replaceHeadQueuedBatch(preserved);
265
519
  log.info(`Mixed variant batch rejected; preserved ${nextChanges.length} non-variant items`);
266
- return this.pendingChanges;
520
+ this.persistQueue();
521
+ this.notifyLivenessChange();
522
+ return preserved;
267
523
  }
268
524
  /**
269
525
  * Mark the currently processing MCP apply run using identifiers from the queued request.
@@ -291,6 +547,11 @@ class SessionBridgeService {
291
547
  this.activeRunCorrelation = null;
292
548
  return correlation;
293
549
  }
550
+ /** Active apply lease, if any (at most one). */
551
+ findActiveApplyLease() {
552
+ const leased = this.applyPipeline.snapshot().leased;
553
+ return leased[0] ?? null;
554
+ }
294
555
  /** Get the number of completed editing rounds in this session */
295
556
  getRoundsCompleted() {
296
557
  return this.roundsCompleted;
@@ -385,12 +646,11 @@ class SessionBridgeService {
385
646
  * preview) on `devServerHealth` so `/api/mcp/status` and the iframe's
386
647
  * `usePreviewUpstreamHealth` probe see `ownership: 'rivet_owned'` + the
387
648
  * new port. Pass `null` to clear back to the no-server state. Distinct
388
- * from `switchPreviewPort` (which only retargets the proxy) and from
649
+ * from `switchPreviewPort` (which temporarily retargets health while a
650
+ * variant preview is active, then restores `baseDevServerHealth`) and from
389
651
  * `setDevServerContext` (which the MCP server uses for the user's own
390
652
  * dev server during a regular session). Only the orchestrator's
391
- * commit-handoff path should call this — variant cycling and cancel
392
- * still go through `switchPreviewPort` and must NOT touch the user's
393
- * external dev-server health.
653
+ * commit-handoff path should call this.
394
654
  */
395
655
  setCommittedDevServerHealth(port) {
396
656
  if (port === null) {
@@ -412,7 +672,7 @@ class SessionBridgeService {
412
672
  const committedDevServerHealth = {
413
673
  ...this.devServerHealth,
414
674
  ownership: 'rivet_owned',
415
- host: '127.0.0.1',
675
+ host: 'localhost',
416
676
  port,
417
677
  isReachable: true,
418
678
  reason: 'healthy',
@@ -431,6 +691,24 @@ class SessionBridgeService {
431
691
  log.info(`switchPreviewPort → ${port === null ? 'default upstream' : `localhost:${port}`}`);
432
692
  }
433
693
  this._setVariantTarget?.(port);
694
+ if (port !== null) {
695
+ // Retarget `/api/mcp/status` upstream probes to the active variant dev
696
+ // server. The proxy already serves this port; without this, health keeps
697
+ // checking the (possibly dead) base external port and the iframe shows
698
+ // "Preview isn't connected" while variant previews are live.
699
+ this.devServerHealth = {
700
+ ownership: 'rivet_owned',
701
+ host: 'localhost',
702
+ port,
703
+ isReachable: true,
704
+ reason: 'healthy',
705
+ lastCheckedAt: Date.now(),
706
+ startedByRivet: true,
707
+ };
708
+ }
709
+ else if (this.baseDevServerHealth) {
710
+ this.devServerHealth = { ...this.baseDevServerHealth };
711
+ }
434
712
  this.reportFeedback('__RIVET_RELOAD_PREVIEW__');
435
713
  }
436
714
  /**
@@ -440,11 +718,22 @@ class SessionBridgeService {
440
718
  endSession() {
441
719
  const rounds = this.roundsCompleted;
442
720
  const id = this.sessionId;
443
- // Cancel any blocking wait
444
- this.waitResolve = null;
721
+ const projectPath = this.projectPath;
722
+ this.waitResolvers = [];
445
723
  this.sessionId = null;
446
724
  this.projectPath = null;
447
- this.pendingChanges = null;
725
+ this.applyPipeline = new BridgeBatchPipeline_1.BridgeBatchPipeline({
726
+ kind: 'apply',
727
+ maxQueued: exports.MAX_PENDING_APPLY_BATCHES,
728
+ maxConcurrentLeases: 1,
729
+ queueFullReason: 'apply_queue_full',
730
+ });
731
+ this.variantPipeline = new BridgeBatchPipeline_1.BridgeBatchPipeline({
732
+ kind: 'variant',
733
+ maxQueued: exports.MAX_PENDING_VARIANT_REQUESTS,
734
+ maxConcurrentLeases: exports.MAX_LEASED_VARIANT_BATCHES,
735
+ queueFullReason: 'variant_queue_full',
736
+ });
448
737
  this.telemetryContext = null;
449
738
  this.roundsCompleted = 0;
450
739
  this.feedback = [];
@@ -462,9 +751,65 @@ class SessionBridgeService {
462
751
  lastCheckedAt: null,
463
752
  startedByRivet: false,
464
753
  };
754
+ if (id && projectPath) {
755
+ BridgeQueueStore_1.bridgeQueueStore.delete(projectPath, id);
756
+ }
465
757
  log.info(`Session ended: ${id}, ${rounds} rounds completed`);
758
+ this.notifyLivenessChange();
466
759
  return rounds;
467
760
  }
761
+ /**
762
+ * Snapshot the real liveness of the change pipeline for stale-watcher
763
+ * detection. `watcherActive` reflects a registered `watch_for_changes` waiter,
764
+ * NOT channel silence; `oldestPendingChangeAgeMs` is measured against `now`
765
+ * (injectable for tests) so a healthy long-poll can never look stuck — only
766
+ * genuinely unconsumed, unwatched work does.
767
+ */
768
+ getWatcherLiveness(now = Date.now()) {
769
+ const oldestPendingChangeEnqueuedAt = this.oldestPendingChangeEnqueuedAt();
770
+ const pendingCount = this.applyPipeline.queuedDepth() + this.variantPipeline.queuedDepth();
771
+ return {
772
+ watcherActive: this.waitResolvers.length > 0,
773
+ pendingCount,
774
+ oldestPendingChangeEnqueuedAt,
775
+ oldestPendingChangeAgeMs: oldestPendingChangeEnqueuedAt === null
776
+ ? null
777
+ : Math.max(0, now - oldestPendingChangeEnqueuedAt),
778
+ };
779
+ }
780
+ /**
781
+ * Subscribe to changes in {@link getWatcherLiveness} state (queue/drain,
782
+ * waiter register/settle, session start/end). Returns an unsubscribe fn.
783
+ */
784
+ onWatcherLivenessChange(listener) {
785
+ this.livenessListeners.add(listener);
786
+ return () => {
787
+ this.livenessListeners.delete(listener);
788
+ };
789
+ }
790
+ /** Oldest enqueue time across queued apply and variant batches, or null. */
791
+ oldestPendingChangeEnqueuedAt() {
792
+ const times = [];
793
+ const applyAt = this.applyPipeline.oldestQueuedEnqueuedAt();
794
+ if (applyAt !== null) {
795
+ times.push(applyAt);
796
+ }
797
+ const variantTimes = this.variantPipeline.peekQueuedEnqueuedAt();
798
+ if (variantTimes.length > 0) {
799
+ times.push(variantTimes[0]);
800
+ }
801
+ return times.length > 0 ? Math.min(...times) : null;
802
+ }
803
+ notifyLivenessChange() {
804
+ for (const listener of this.livenessListeners) {
805
+ try {
806
+ listener();
807
+ }
808
+ catch (err) {
809
+ log.warn('liveness listener threw', err);
810
+ }
811
+ }
812
+ }
468
813
  }
469
814
  exports.SessionBridgeService = SessionBridgeService;
470
815
  //# sourceMappingURL=SessionBridgeService.js.map