mandala-computer-mcp 0.1.1 → 0.4.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.
Files changed (72) hide show
  1. package/README.md +139 -24
  2. package/dist/api.d.ts +19 -6
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +355 -76
  5. package/dist/api.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +113 -8
  8. package/dist/cli.js.map +1 -1
  9. package/dist/errors.d.ts +100 -12
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/errors.js +169 -29
  12. package/dist/errors.js.map +1 -1
  13. package/dist/events.d.ts +45 -4
  14. package/dist/events.d.ts.map +1 -1
  15. package/dist/events.js +422 -114
  16. package/dist/events.js.map +1 -1
  17. package/dist/format.d.ts +48 -0
  18. package/dist/format.d.ts.map +1 -1
  19. package/dist/format.js +111 -4
  20. package/dist/format.js.map +1 -1
  21. package/dist/http-body.d.ts +17 -0
  22. package/dist/http-body.d.ts.map +1 -0
  23. package/dist/http-body.js +48 -0
  24. package/dist/http-body.js.map +1 -0
  25. package/dist/http.d.ts.map +1 -1
  26. package/dist/http.js +177 -51
  27. package/dist/http.js.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/limits.d.ts +17 -0
  33. package/dist/limits.d.ts.map +1 -0
  34. package/dist/limits.js +17 -0
  35. package/dist/limits.js.map +1 -0
  36. package/dist/paths.d.ts +30 -20
  37. package/dist/paths.d.ts.map +1 -1
  38. package/dist/paths.js +89 -25
  39. package/dist/paths.js.map +1 -1
  40. package/dist/poll.d.ts +107 -0
  41. package/dist/poll.d.ts.map +1 -0
  42. package/dist/poll.js +233 -0
  43. package/dist/poll.js.map +1 -0
  44. package/dist/server.d.ts +1 -1
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +2 -1
  47. package/dist/server.js.map +1 -1
  48. package/dist/tools/agent.d.ts.map +1 -1
  49. package/dist/tools/agent.js +72 -5
  50. package/dist/tools/agent.js.map +1 -1
  51. package/dist/tools/computers.d.ts.map +1 -1
  52. package/dist/tools/computers.js +559 -233
  53. package/dist/tools/computers.js.map +1 -1
  54. package/dist/tools/events.d.ts.map +1 -1
  55. package/dist/tools/events.js +359 -69
  56. package/dist/tools/events.js.map +1 -1
  57. package/dist/tools/guest.d.ts.map +1 -1
  58. package/dist/tools/guest.js +234 -33
  59. package/dist/tools/guest.js.map +1 -1
  60. package/dist/tools/input.d.ts.map +1 -1
  61. package/dist/tools/input.js +92 -8
  62. package/dist/tools/input.js.map +1 -1
  63. package/dist/tools/snapshots.d.ts.map +1 -1
  64. package/dist/tools/snapshots.js +501 -33
  65. package/dist/tools/snapshots.js.map +1 -1
  66. package/dist/tools/templates.d.ts.map +1 -1
  67. package/dist/tools/templates.js +61 -26
  68. package/dist/tools/templates.js.map +1 -1
  69. package/dist/tools/webhooks.d.ts.map +1 -1
  70. package/dist/tools/webhooks.js +116 -17
  71. package/dist/tools/webhooks.js.map +1 -1
  72. package/package.json +3 -2
@@ -23,7 +23,7 @@ const KNOWN_TYPES = [
23
23
  'window.focused',
24
24
  'window.blurred',
25
25
  'clipboard.changed',
26
- // The one type nobody is sent unasked (platform OPL-3927). It is here so a
26
+ // The one type nobody is sent unasked (OPL-3927). It is here so a
27
27
  // model reading this list knows it exists; a wait that names it and nothing
28
28
  // else, on a stream watching nothing, is answered with the sentence that says
29
29
  // how to make one arrive rather than with a timeout.
@@ -56,6 +56,8 @@ const KNOWN_TYPES = [
56
56
  const MAX_WAIT_S = 55;
57
57
  /** How long a first call gives the socket to reach its opening frame. */
58
58
  const ATTACH_MS = 20_000;
59
+ /** Optional context must not hold an already-consumed event answer indefinitely. */
60
+ const RECONCILE_MS = 2_000;
59
61
  /**
60
62
  * Wait for a subscription to say something about itself, inside a budget.
61
63
  *
@@ -93,20 +95,87 @@ async function attached(sub, cancel, deadline) {
93
95
  * the caller already has, and a windows listing that 409s because the guest is
94
96
  * busy must not turn a delivered event into a failed tool call.
95
97
  */
96
- async function reconcile(session, id, signal) {
97
- const api = session.api.with(signal);
98
- const state = {};
99
- const [windows, computer] = await Promise.allSettled([
100
- api.json('GET', P.computerAction(id, 'windows')),
101
- api.json('GET', P.computer(id)),
98
+ async function reconcile(session, id, cancel, deadline) {
99
+ const signal = AbortSignal.any([
100
+ AbortSignal.timeout(RECONCILE_MS),
101
+ ...(cancel ? [cancel] : []),
102
+ ...(deadline ? [deadline] : []),
102
103
  ]);
103
- if (windows.status === 'fulfilled')
104
- state.windows_now = windows.value;
105
- if (computer.status === 'fulfilled') {
106
- state.computer_now = withoutCredentials(unwrapComputer(computer.value));
104
+ const state = {};
105
+ if (!signal.aborted) {
106
+ const api = session.api.with(signal);
107
+ let end = () => { };
108
+ const expired = new Promise((resolve) => {
109
+ end = resolve;
110
+ signal.addEventListener('abort', end, { once: true });
111
+ });
112
+ try {
113
+ // Keep whichever half arrived before the deadline. The race also bounds
114
+ // embedders whose fetch implementation does not honour cancellation.
115
+ await Promise.race([
116
+ expired,
117
+ Promise.allSettled([
118
+ api.json('GET', P.computerAction(id, 'windows')).then((windows) => {
119
+ if (!signal.aborted)
120
+ state.windows_now = windows;
121
+ }),
122
+ api.json('GET', P.computer(id)).then((computer) => {
123
+ if (!signal.aborted) {
124
+ state.computer_now = withoutCredentials(unwrapComputer(computer));
125
+ }
126
+ }),
127
+ ]),
128
+ ]);
129
+ }
130
+ finally {
131
+ signal.removeEventListener('abort', end);
132
+ }
133
+ }
134
+ if (!('windows_now' in state) || !('computer_now' in state)) {
135
+ state.reconciliation_note = reconciled(state).trim();
107
136
  }
108
137
  return state;
109
138
  }
139
+ /** No continuity claim based solely on capabilities cached from an old socket. */
140
+ function connectionNote(sub, version) {
141
+ if (sub.state.status === 'stopped') {
142
+ return 'The event stream has stopped. These are the events buffered so far; call again for the remaining events and the reason it stopped.';
143
+ }
144
+ if (!sub.connected) {
145
+ return 'The event stream is reconnecting. These are the events buffered so far; more history may arrive on replay, so silence here is not an answer about what the computer did. Call again.';
146
+ }
147
+ if (version === undefined || version !== sub.connectionVersion) {
148
+ return 'The event stream was interrupted during this wait and has reopened. These are the events buffered so far; replayed events will be returned as they arrive. Call again to keep waiting.';
149
+ }
150
+ return '';
151
+ }
152
+ /**
153
+ * The sentence that points at the reconciliation — naming only the keys it
154
+ * actually produced.
155
+ *
156
+ * `reconcile` assigns each key on a fulfilled read and swallows the rest, so
157
+ * `{}` is a real return value: the windows read 409s while the guest is busy,
158
+ * the computer read fails, or the caller's own signal aborts both. Naming
159
+ * `windows_now` and `computer_now` regardless sends a model looking for two
160
+ * keys that are not in the payload — a wasted turn on the one answer that is
161
+ * already admitting a hole, and the tool that has to be believed about holes.
162
+ */
163
+ function reconciled(extras) {
164
+ const reads = [
165
+ { key: 'windows_now', tool: 'list_windows' },
166
+ { key: 'computer_now', tool: 'get_computer' },
167
+ ];
168
+ const here = reads.filter((r) => r.key in extras).map((r) => r.key);
169
+ const absent = reads.filter((r) => !(r.key in extras)).map((r) => r.tool);
170
+ if (!here.length) {
171
+ return (' See lost for what is known about it. Where the computer actually stands could NOT be read ' +
172
+ 'just now, so it is not in this answer — call list_windows and get_computer for what the ' +
173
+ 'missing events would have told you.');
174
+ }
175
+ return (` See lost for what is known about it, and ${here.join(' and ')} for where the computer ` +
176
+ `actually stands, which is what the missing events would have told you.` +
177
+ (absent.length ? ` The other half could not be read just now — call ${absent[0]} for it.` : ''));
178
+ }
110
179
  /**
111
180
  * Why a computer is missing part of the guest half, in words.
112
181
  *
@@ -143,7 +212,7 @@ function guestHalf(can) {
143
212
  // too, so a computer reporting it plainly has one.
144
213
  return ('It does report the desktop half, which needs the same terminal channel a file watch runs ' +
145
214
  'over — so the channel is there and it is the file watch that is missing. The host holding ' +
146
- 'this computer predates them (platform OPL-3927), and there is nothing to do about that ' +
215
+ 'this computer predates them (OPL-3927), and there is nothing to do about that ' +
147
216
  'from here.');
148
217
  }
149
218
  if (files && desktop) {
@@ -193,7 +262,7 @@ function name(ev) {
193
262
  return detail ? `${ev.type} (${detail})` : ev.type;
194
263
  }
195
264
  /** The body these tools answer with, minus the keys there is nothing to say about. */
196
- function body(id, d, watching) {
265
+ function body(id, d, sub, watching) {
197
266
  const out = { computer: id, events: d.events, cursor: d.cursor };
198
267
  if (d.more)
199
268
  out.more_waiting = d.more;
@@ -204,14 +273,22 @@ function body(id, d, watching) {
204
273
  // another call can evict it, and a reconnect can find it disarmed. A reader
205
274
  // that had to remember which of four trees was live from a call several turns
206
275
  // ago is a reader that will get it wrong.
207
- if (watching?.length)
208
- out.watching = watching;
276
+ if (watching?.length) {
277
+ out.watching = watching.map((watch) => ({ ...watch, armed: watch.armed && sub.connected }));
278
+ }
209
279
  // Only where it is news. `can_emit` is what stops a model waiting for
210
280
  // something this machine will never produce, and the opening frame is the one
211
281
  // place that answer exists — but repeating it on every poll would be a field
212
282
  // that means nothing on the ninety-ninth call.
283
+ //
284
+ // Read from the LIVE list rather than from the greeting that seeded it. A
285
+ // `capabilities` frame replaces what `hello` advertised and goes both ways —
286
+ // a guest that turns out to have no watcher withdraws the half `hello`
287
+ // promised — and one landing between the opening frame and this subscription's
288
+ // first read would otherwise publish, exactly once and never again, a
289
+ // vocabulary that contradicts the one every refusal path here already acts on.
213
290
  if (d.attached && d.hello) {
214
- out.can_emit = d.hello.events;
291
+ out.can_emit = sub.eventTypes ?? d.hello.events;
215
292
  if (d.hello.windows)
216
293
  out.windows_on_attach = d.hello.windows;
217
294
  }
@@ -255,8 +332,22 @@ function stopped(session, id, reason, sub, read) {
255
332
  const d = sub.read(read);
256
333
  const n = d.events.length;
257
334
  const drained = !d.more;
335
+ // The sub this call drained, named, so the drop cannot take a replacement.
336
+ // Two calls on one computer overlap by design here, and a second one holding
337
+ // a handle this one has already dropped would otherwise close the fresh
338
+ // stream opened in between.
258
339
  if (drained)
259
- session.events.drop(id, reason, true);
340
+ session.events.drop(id, reason, true, sub);
341
+ // KNOWN GAP on the `!drained` side of that same overlap: if this call is the
342
+ // one holding the older handle, "call again for them" is a promise this
343
+ // server cannot keep. The next call resolves through `EventHub.open()`, which
344
+ // returns the subscription now in `#subs` — the replacement — and the events
345
+ // still in THIS one's ring are unreachable from that moment. Reaching it
346
+ // needs two calls overlapping on one computer with the older one parked
347
+ // mid-wait, which is why it has no test. Anything written here has to be
348
+ // narrower than "a fresh stream is running, there is nothing to fix": that
349
+ // sentence was tried, and it both dropped the disclosure above and reported a
350
+ // replacement that had itself stopped as healthy.
260
351
  const held = n
261
352
  ? `${n} event${n === 1 ? '' : 's'} had already arrived before it stopped and ${n === 1 ? 'is' : 'are'} ` +
262
353
  (drained
@@ -275,7 +366,7 @@ function stopped(session, id, reason, sub, read) {
275
366
  // Without the watch set, deliberately. This stream has stopped, so a tree
276
367
  // it was carrying is a tree nothing is watching — and `watching` reports
277
368
  // `armed` from the last opening frame, which would read as live.
278
- n || d.loss ? body(id, d) : undefined);
369
+ n || d.loss ? body(id, d, sub) : undefined);
279
370
  }
280
371
  /**
281
372
  * A stream that has not yet got as far as its opening frame.
@@ -357,6 +448,21 @@ export const registerEvents = (server, session) => {
357
448
  // had already taken — the same defect one tool over, with a ring in this
358
449
  // session instead of a cursor in the guest. Nothing is created and nothing
359
450
  // is destroyed, which is what made the annotation look right.
451
+ // No readOnlyHint, for the reason above — and BOTH other flags set,
452
+ // because the spec's defaults are wrong in one direction and unstated in
453
+ // the other the moment readOnlyHint is absent (OPL-4516).
454
+ //
455
+ // destructiveHint would default to TRUE, so without it this asks a host
456
+ // whether it may perform DESTRUCTIVE UPDATES in order to read.
457
+ // `cursor_position` and `read_file` set it for that reason; these were not
458
+ // revisited with them.
459
+ //
460
+ // idempotentHint is FALSE, which is the difference from those two, and it
461
+ // is set rather than left to its default for the same reason as the other:
462
+ // a host reading an absent flag as "unknown, safe to retry" would retry a
463
+ // timed-out poll and silently drop what the first attempt consumed, which
464
+ // is the harm the paragraph above it is about.
465
+ annotations: { destructiveHint: false, idempotentHint: false },
360
466
  }, ({ computer_id, types, pid, timeout_s, since, limit }, extra) => guarded(async () => {
361
467
  const id = session.resolve(computer_id);
362
468
  const sub = session.events.open(id);
@@ -374,14 +480,16 @@ export const registerEvents = (server, session) => {
374
480
  // point in this call, and a `state` captured before the wait is a
375
481
  // statement about a moment that has passed.
376
482
  const opening = sub.state;
483
+ // A cancelled call must not drain a stopped subscription's final
484
+ // buffered delivery into an answer its caller can no longer receive.
485
+ if (extra.signal?.aborted)
486
+ return cancelledDuringAttach(id);
377
487
  if (opening.status === 'stopped') {
378
488
  return stopped(session, id, opening.reason, sub, { since, limit });
379
489
  }
380
- // A caller who hung up is not a stream that failed to open.
381
- if (extra.signal?.aborted)
382
- return cancelledDuringAttach(id);
383
490
  if (!sub.eventTypes)
384
491
  return unattached(id, Date.now() - started);
492
+ const connection = sub.connected ? sub.connectionVersion : undefined;
385
493
  // A `pid` on its own means the exit of THAT command. Without this the
386
494
  // filter below reads "anything that is not a process.exited passes",
387
495
  // and `wait_for_event({pid: 99})` — which is how the argument's own
@@ -504,12 +612,13 @@ export const registerEvents = (server, session) => {
504
612
  const waited = Math.round((Date.now() - started) / 1000);
505
613
  if (hit === undefined) {
506
614
  if (extra.signal?.aborted) {
507
- return refused(`Cancelled while waiting on ${id}. The stream is still open and still buffering — ` +
508
- `nothing was missed.`);
615
+ return refused(`Cancelled while waiting on ${id}. Buffered events remain available on the next call. ` +
616
+ (connectionNote(sub, connection) || 'The stream is still open and buffering.'));
617
+ }
618
+ const stoppedWhileWaiting = sub.state;
619
+ if (stoppedWhileWaiting.status === 'stopped') {
620
+ return stopped(session, id, stoppedWhileWaiting.reason, sub, { since, limit });
509
621
  }
510
- const now = sub.state;
511
- if (now.status === 'stopped')
512
- return stopped(session, id, now.reason, sub, { since, limit });
513
622
  // Asked again, because the wait that just ended is long enough for a
514
623
  // `capabilities` frame to have arrived inside it. A withdrawal that
515
624
  // happened while parked is still the reason nothing came, and saying
@@ -525,8 +634,19 @@ export const registerEvents = (server, session) => {
525
634
  // which is an answer, and it comes with the cursor that makes asking
526
635
  // again cost nothing. Reporting it as a failure would teach a model to
527
636
  // stop asking — back to the screenshot loop this tool exists to end.
637
+ const needsReconciliation = sub.needsReconciliation({ since, limit });
638
+ const recovered = needsReconciliation
639
+ ? await reconcile(session, id, extra.signal, deadline)
640
+ : {};
641
+ if (extra.signal?.aborted) {
642
+ return refused(`Cancelled while waiting on ${id}. Buffered events remain available on the next call. ` +
643
+ (connectionNote(sub, connection) || 'The stream is still open and buffering.'));
644
+ }
645
+ const now = sub.state;
646
+ if (now.status === 'stopped')
647
+ return stopped(session, id, now.reason, sub, { since, limit });
528
648
  const d = sub.read({ since, limit });
529
- const extras = d.loss ? await reconcile(session, id, extra.signal) : {};
649
+ const extras = d.loss ? recovered : {};
530
650
  // What did NOT match still happened, and this read has just handed it
531
651
  // over — so the sentence has to name it. Saying "nothing happened"
532
652
  // over a payload holding three events is the one thing a model must
@@ -537,6 +657,11 @@ export const registerEvents = (server, session) => {
537
657
  ? ` ${d.events.length} other event${d.events.length === 1 ? '' : 's'} did happen and ` +
538
658
  `${d.events.length === 1 ? 'is' : 'are'} below.`
539
659
  : '';
660
+ const interrupted = connectionNote(sub, connection);
661
+ if (interrupted || d.loss) {
662
+ return said(`${interrupted || `No matching event is buffered on ${id}.`}${others}` +
663
+ (d.loss ? reconciled(extras) : ''), { ...body(id, d, sub, sub.watching), ...extras });
664
+ }
540
665
  return said(`Nothing ${wanted ? `matching ${[...wanted].join(' or ')} ` : ''}happened on ${id} in ` +
541
666
  `${timeout_s}s.${others} This server kept listening the whole time and is still ` +
542
667
  `listening — nothing was missed and nothing is being missed now. Call again to ` +
@@ -545,20 +670,35 @@ export const registerEvents = (server, session) => {
545
670
  // wait was real: what must not happen is a model reading "nothing
546
671
  // happened" as covering a type nothing was ever going to send.
547
672
  (unwatched() ? ` One thing was NOT being waited for: ${nominate()}` : '') +
548
- (d.loss ? ' Some events were lost before they could be read — see lost.' : ''), { ...body(id, d, sub.watching), ...extras });
673
+ (d.loss ? ' Some events were lost before they could be read — see lost.' : ''), { ...body(id, d, sub, sub.watching), ...extras });
674
+ }
675
+ const needsReconciliation = sub.needsReconciliation({ since, limit, through: hit });
676
+ const recovered = needsReconciliation
677
+ ? await reconcile(session, id, extra.signal, deadline)
678
+ : {};
679
+ if (extra.signal?.aborted) {
680
+ return refused(`Cancelled while waiting on ${id}. Buffered events remain available on the next call. ` +
681
+ (connectionNote(sub, connection) || 'The stream is still open and buffering.'));
549
682
  }
550
683
  const d = sub.read({ since, limit, through: hit });
551
684
  const last = d.events[d.events.length - 1];
552
- const extras = d.loss ? await reconcile(session, id, extra.signal) : {};
685
+ const extras = d.loss ? recovered : {};
553
686
  const before = d.events.length - 1;
554
687
  // Empty when another call on this computer consumed the matched event
555
688
  // first: the ring is one buffer with one delivered cursor, and two
556
689
  // overlapping waits can both match before either reads. Naming an event
557
690
  // over an empty list would be an event the caller is never shown.
558
691
  if (!last) {
692
+ if (d.loss) {
693
+ return said(`An event on ${id} matched this wait, but the matching event is no longer in this ` +
694
+ `call's delivery. Some buffered history was lost before it could be read, so do ` +
695
+ `not assume another reader safely received the match.` +
696
+ reconciled(extras) +
697
+ ` Call again for the events that remain buffered.`, { ...body(id, d, sub, sub.watching), ...extras });
698
+ }
559
699
  return said(`Something happened on ${id} and another call on this computer was handed it before ` +
560
700
  `this one could read it — the events are in that call's answer, not below. Nothing ` +
561
- `is lost; look there, or call again for whatever comes next.`, { ...body(id, d, sub.watching), ...extras });
701
+ `is lost; look there, or call again for whatever comes next.`, { ...body(id, d, sub, sub.watching), ...extras });
562
702
  }
563
703
  return said(`${name(last)} on ${id} after ${waited}s` +
564
704
  (before > 0 ? `, and ${before} before it` : '') +
@@ -568,7 +708,7 @@ export const registerEvents = (server, session) => {
568
708
  'and computer.ready is announced once per desktop session — so the real event had ' +
569
709
  'happened before there was anything here to hear it, and waiting for it would have ' +
570
710
  'waited forever.'
571
- : ''), { ...body(id, d, sub.watching), ...extras });
711
+ : ''), { ...body(id, d, sub, sub.watching), ...extras });
572
712
  }));
573
713
  /**
574
714
  * What one `file.changed` says, in a sentence.
@@ -691,6 +831,21 @@ export const registerEvents = (server, session) => {
691
831
  // tree out of the watch set. A client treating the hint as licence to
692
832
  // retry a call that timed out would silently drop events and, on the
693
833
  // fifth distinct path, silently stop watching the first.
834
+ // No readOnlyHint, for the two reasons above — and destructiveHint TRUE,
835
+ // which is where this parts company with the three consuming reads beside
836
+ // it (OPL-4516).
837
+ //
838
+ // The spec reads `false` as "performs only additive updates", and this does
839
+ // not: a nomination past MAX_WATCHES evicts the least recent tree and
840
+ // deletes its armed, lost and host state, so a fifth call ends event
841
+ // delivery on the first with nothing said. Claiming additive-only would be
842
+ // the same false all-clear the missing readOnlyHint already avoids — a host
843
+ // that auto-approves non-destructive tools would let the model do it with
844
+ // nobody asked. Same value the absent annotation defaulted to, said out
845
+ // loud so it reads as a decision rather than an omission.
846
+ //
847
+ // idempotentHint false for the reason the others set it: this consumes.
848
+ annotations: { destructiveHint: true, idempotentHint: false },
694
849
  }, ({ computer_id, path, timeout_s, since, limit }, extra) => guarded(async () => {
695
850
  const id = session.resolve(computer_id);
696
851
  // Before anything is opened. A path this host will not accept is a 400
@@ -715,11 +870,11 @@ export const registerEvents = (server, session) => {
715
870
  const started = Date.now();
716
871
  await attached(sub, extra.signal, deadline);
717
872
  const opening = sub.state;
873
+ if (extra.signal?.aborted)
874
+ return cancelledDuringAttach(id);
718
875
  if (opening.status === 'stopped') {
719
876
  return stopped(session, id, opening.reason, sub, { since, limit });
720
877
  }
721
- if (extra.signal?.aborted)
722
- return cancelledDuringAttach(id);
723
878
  if (!sub.eventTypes)
724
879
  return unattached(id, Date.now() - started);
725
880
  // Asked before the tree is nominated, because a computer that cannot
@@ -748,11 +903,11 @@ export const registerEvents = (server, session) => {
748
903
  if (!sub.nominationLive(root)) {
749
904
  await sub.nominated(root, deadline, extra.signal);
750
905
  const after = sub.state;
906
+ if (extra.signal?.aborted)
907
+ return cancelledWhileArming(id, root);
751
908
  if (after.status === 'stopped') {
752
909
  return stopped(session, id, after.reason, sub, { since, limit });
753
910
  }
754
- if (extra.signal?.aborted)
755
- return cancelledWhileArming(id, root);
756
911
  // ASKED FIRST, all four of them, because each is an answer and the
757
912
  // timeout below is only the absence of one. A tree the host would not
758
913
  // carry, one another call evicted, or a computer that has stopped
@@ -777,7 +932,7 @@ export const registerEvents = (server, session) => {
777
932
  if (sub.watchesHonoured === false) {
778
933
  return refused(`${id} opened its event stream but said nothing about ${root}, so this server cannot ` +
779
934
  `tell whether the tree is being watched — and a wait would be a wait on silence. The ` +
780
- `host holding this computer may predate file watches (platform OPL-3927). Use exec ` +
935
+ `host holding this computer may predate file watches (OPL-3927). Use exec ` +
781
936
  `to look at the directory instead.` +
782
937
  evicted);
783
938
  }
@@ -786,10 +941,10 @@ export const registerEvents = (server, session) => {
786
941
  await sub.armedWait(root, deadline, extra.signal);
787
942
  if (!sub.isArmed(root)) {
788
943
  const now = sub.state;
789
- if (now.status === 'stopped')
790
- return stopped(session, id, now.reason, sub, { since, limit });
791
944
  if (extra.signal?.aborted)
792
945
  return cancelledWhileArming(id, wire);
946
+ if (now.status === 'stopped')
947
+ return stopped(session, id, now.reason, sub, { since, limit });
793
948
  // Asked before the sentence below, all four of them, because that
794
949
  // sentence says the nomination stands and this tree is still coming
795
950
  // up — and every word of it is false when the tree has been evicted
@@ -817,6 +972,7 @@ export const registerEvents = (server, session) => {
817
972
  // whatever happened in between was never reported and the tree has to
818
973
  // be re-read.
819
974
  const generation = sub.armGeneration(root);
975
+ const connection = sub.connected ? sub.connectionVersion : undefined;
820
976
  // Said once, on the first answer about a tree this subscription
821
977
  // inherited from one that went away. A tree is watched by the
822
978
  // CONNECTION, so the idle reap that took the previous subscription also
@@ -839,6 +995,49 @@ export const registerEvents = (server, session) => {
839
995
  `a watch lives on the connection. Anything that changed in that window was never ` +
840
996
  `reported and cannot be. Re-read the directory with exec if it matters.`
841
997
  : '';
998
+ // Kept by the subscription rather than inferred from this call's
999
+ // generation snapshot. A re-arm can arrive while no tool call exists,
1000
+ // and snapshotting after it would otherwise erase the only evidence of
1001
+ // the unreported window. Delete the marker here, where its explanation
1002
+ // is rendered, so unrelated reads and cancelled waits cannot consume it.
1003
+ const rearmNotice = () => `The watch on ${wire} was re-armed after an interruption — a stop and a start, a ` +
1004
+ `guest reboot, a broker replaced. Reporting starts again HERE, and nothing that ` +
1005
+ `happened to the tree while it was down was reported or ever will be. Re-read the ` +
1006
+ `directory with exec if that window matters, then call again to keep waiting.`;
1007
+ const cancelledWait = () => refused(`Cancelled while waiting on ${wire}. Nothing was missed by the cancellation — this ` +
1008
+ `server holds the stream and its buffer between calls — but nothing was checked ` +
1009
+ `about the watch either, so call again for an answer about the tree.` +
1010
+ interrupted());
1011
+ if (sub.hasUndisclosedRearm(root)) {
1012
+ const needsReconciliation = sub.needsReconciliation({ since, limit });
1013
+ const recovered = needsReconciliation
1014
+ ? await reconcile(session, id, extra.signal, deadline)
1015
+ : {};
1016
+ if (extra.signal?.aborted)
1017
+ return cancelledWait();
1018
+ const after = sub.state;
1019
+ if (after.status === 'stopped')
1020
+ return stopped(session, id, after.reason, sub, { since, limit });
1021
+ const d = sub.read({ since, limit });
1022
+ const extras = d.loss ? recovered : {};
1023
+ const answer = { ...body(id, d, sub, sub.watching), watch: wire, ...extras };
1024
+ const why = settled(sub, id, root, wire, () => interrupted() + evicted, answer);
1025
+ if (why)
1026
+ return why;
1027
+ // A competing same-tree wait may have explained it while reconcile
1028
+ // was in flight. Only the call that claims the marker repeats it.
1029
+ if (sub.takeUndisclosedRearm(root)) {
1030
+ return said(rearmNotice() + interrupted() + renamed + evicted, answer);
1031
+ }
1032
+ return said(`Another call on ${id} reported the watch interruption while this call was ` +
1033
+ `reconciling it. This call's delivery is below.` +
1034
+ (d.loss
1035
+ ? ` Some buffered history was lost before it could be read.${reconciled(extras)}`
1036
+ : ` No buffered event was dropped.`) +
1037
+ interrupted() +
1038
+ renamed +
1039
+ evicted, answer);
1040
+ }
842
1041
  // When the waiting actually started, which is not when the call did.
843
1042
  // `timeout_s` bounds the whole call — it has to, because a client
844
1043
  // cancels a request that outlives its own timeout — so a call that
@@ -892,38 +1091,45 @@ export const registerEvents = (server, session) => {
892
1091
  // call did not check and which a cancel racing an eviction or a
893
1092
  // shed makes false. What IS true is the part that matters: the
894
1093
  // buffer is on this side and nothing in it went anywhere.
895
- return refused(`Cancelled while waiting on ${wire}. Nothing was missed by the cancellation — this ` +
896
- `server holds the stream and its buffer between calls — but nothing was checked ` +
897
- `about the watch either, so call again for an answer about the tree.` +
898
- interrupted());
1094
+ return cancelledWait();
899
1095
  }
900
1096
  const now = sub.state;
901
1097
  if (now.status === 'stopped')
902
1098
  return stopped(session, id, now.reason, sub, { since, limit });
1099
+ const needsReconciliation = sub.needsReconciliation({ since, limit });
1100
+ const recovered = needsReconciliation
1101
+ ? await reconcile(session, id, extra.signal, deadline)
1102
+ : {};
1103
+ if (extra.signal?.aborted)
1104
+ return cancelledWait();
1105
+ const after = sub.state;
1106
+ if (after.status === 'stopped')
1107
+ return stopped(session, id, after.reason, sub, { since, limit });
903
1108
  const d = sub.read({ since, limit });
904
- const extras = d.loss ? await reconcile(session, id, extra.signal) : {};
905
- const answer = { ...body(id, d, sub.watching), watch: wire, ...extras };
1109
+ const extras = d.loss ? recovered : {};
1110
+ const answer = { ...body(id, d, sub, sub.watching), watch: wire, ...extras };
906
1111
  // THESE FIRST, and in this order, because each would otherwise be
907
- // described as something else. Evicting a tree does not reset its arm
908
- // generation — that is kept monotonic on purpose — but it does take
909
- // the tree out of the watch set, and the generation branch below would
910
- // call that a re-arm and invite the caller to go on waiting on a tree
911
- // nothing nominates. A tree the host would not carry is missing from
1112
+ // described as something else. Evicting a tree drops its arm
1113
+ // generation along with the rest of its metadata, so the generation
1114
+ // branch below reads an eviction as a change and would call it a
1115
+ // re-arm — inviting the caller to go on waiting on a tree nothing
1116
+ // nominates. Membership is what that actually is, and `settled` is
1117
+ // where it is asked. A tree the host would not carry is missing from
912
1118
  // the set for a quite different reason, which is why the refusal is
913
1119
  // asked about ahead of the membership.
1120
+ //
1121
+ // Generations stay unambiguous across a re-nomination because they
1122
+ // come from a subscription-wide counter: a tree that comes back gets
1123
+ // a number no waiter can be holding, rather than restarting at one.
914
1124
  const why = settled(sub, id, root, wire, () => interrupted() + evicted, {
915
- ...body(id, d, sub.watching),
1125
+ ...body(id, d, sub, sub.watching),
916
1126
  ...extras,
917
1127
  });
918
1128
  if (why)
919
1129
  return why;
920
1130
  if (sub.armGeneration(root) !== generation) {
921
- return said(`The watch on ${wire} was re-armed after an interruption — a stop and a start, a ` +
922
- `guest reboot, a broker replaced. Reporting starts again HERE, and nothing that ` +
923
- `happened to the tree while it was down was reported or ever will be. Re-read the ` +
924
- `directory with exec if that window matters, then call again to keep waiting.` +
925
- interrupted() +
926
- evicted, answer);
1131
+ sub.takeUndisclosedRearm(root);
1132
+ return said(rearmNotice() + interrupted() + evicted, answer);
927
1133
  }
928
1134
  if (!sub.isArmed(root)) {
929
1135
  return refused(`${wire} on ${id} stopped being watched while this call was waiting, so NOTHING can ` +
@@ -959,6 +1165,15 @@ export const registerEvents = (server, session) => {
959
1165
  interrupted() +
960
1166
  evicted, answer);
961
1167
  }
1168
+ const connectionChanged = connectionNote(sub, connection);
1169
+ if (connectionChanged || d.loss) {
1170
+ return said(`${connectionChanged || `No file change is buffered for ${wire}.`} ` +
1171
+ `A quiet directory cannot be confirmed for this whole wait.` +
1172
+ (d.loss ? reconciled(extras) : '') +
1173
+ interrupted() +
1174
+ renamed +
1175
+ evicted, answer);
1176
+ }
962
1177
  // NOT an error, for the reason wait_for_event's timeout is not: the
963
1178
  // tree was being watched for the whole of it, so "nothing changed" is
964
1179
  // an answer rather than an absence of one. This is the sentence the
@@ -977,19 +1192,50 @@ export const registerEvents = (server, session) => {
977
1192
  evicted +
978
1193
  (d.loss ? ' Some events were lost before they could be read — see lost.' : ''), answer);
979
1194
  }
1195
+ const needsReconciliation = sub.needsReconciliation({ since, limit, through: hit });
1196
+ const recovered = needsReconciliation
1197
+ ? await reconcile(session, id, extra.signal, deadline)
1198
+ : {};
1199
+ if (extra.signal?.aborted)
1200
+ return cancelledWait();
980
1201
  const d = sub.read({ since, limit, through: hit });
981
1202
  const last = d.events[d.events.length - 1];
982
- const extras = d.loss ? await reconcile(session, id, extra.signal) : {};
1203
+ const extras = d.loss ? recovered : {};
983
1204
  const earlier = d.events.length - 1;
984
1205
  // The one `lost` that is not a re-read: it says the tree is not being
985
1206
  // watched, so it is the request failing rather than the watch reporting.
986
1207
  // It arrives as an ordinary file.changed and would otherwise be
987
1208
  // described by changeLine, which has nothing useful to say about it.
988
1209
  if ((last?.data?.lost ?? '') === 'unwatchable') {
989
- return refused(`${unwatchable(wire)} It was being watched until now; from here it is not.` +
1210
+ // Only when the tree is not being watched NOW. An `unwatchable` sits
1211
+ // in the ring until something reads it, and a caller who created the
1212
+ // directory and called again re-nominates: the guest answers the new
1213
+ // nomination with an arm, and this marker — already reported to the
1214
+ // call that refused on it — is then describing a watch that has since
1215
+ // been replaced. Handed back as the answer it refuses a second time
1216
+ // over a directory that plainly exists, which is the same false
1217
+ // "not there yet" the re-nomination exists to clear, one call later.
1218
+ //
1219
+ // `isArmed` is what tells the two apart, and it is exact rather than a
1220
+ // proxy: `unwatchable` is the one `lost` that disarms, so a tree still
1221
+ // carrying this marker as its live state reads false here, while one
1222
+ // the guest has since armed reads true. Said with the re-arm's own
1223
+ // sentence, because that is what happened and the window is genuinely
1224
+ // unreported either way.
1225
+ if (!sub.isArmed(root)) {
1226
+ return refused(`${unwatchable(wire)} It was being watched until now; from here it is not.` +
1227
+ interrupted() +
1228
+ renamed +
1229
+ evicted, { ...body(id, d, sub, sub.watching), watch: wire, ...extras });
1230
+ }
1231
+ return said(`${wire} on ${id} was not watchable when this stream last asked — it was reported ` +
1232
+ `missing or unreadable — and it is being watched now: the nomination was retried and ` +
1233
+ `the guest armed it. Reporting starts HERE, so nothing that happened under it before ` +
1234
+ `this was reported or ever will be. Re-read the directory with exec if that window ` +
1235
+ `matters, then call again to wait for what comes next.` +
990
1236
  interrupted() +
991
1237
  renamed +
992
- evicted, { ...body(id, d, sub.watching), watch: wire, ...extras });
1238
+ evicted, { ...body(id, d, sub, sub.watching), watch: wire, ...extras });
993
1239
  }
994
1240
  // A `through` read can come back empty when another call on this
995
1241
  // computer consumed the matched event first — the ring is one buffer
@@ -997,19 +1243,29 @@ export const registerEvents = (server, session) => {
997
1243
  // before either reads. Announcing "a change" over an empty list would be
998
1244
  // a change the caller is never shown.
999
1245
  if (!last) {
1246
+ if (d.loss) {
1247
+ return said(`A file change under ${wire} on ${id} matched this wait, but the matching event is ` +
1248
+ `no longer in this call's delivery. Some buffered history was lost before it ` +
1249
+ `could be read, so do not assume another reader safely received the match.` +
1250
+ reconciled(extras) +
1251
+ ` Re-read the directory with exec, then call again for whatever comes next.` +
1252
+ interrupted() +
1253
+ renamed +
1254
+ evicted, { ...body(id, d, sub, sub.watching), watch: wire, ...extras });
1255
+ }
1000
1256
  return said(`Something changed under ${wire} on ${id}, and another call on this computer was ` +
1001
1257
  `handed it before this one could read it — the events are in that call's answer, ` +
1002
1258
  `not below. Nothing is lost; look there, or call again for whatever comes next.` +
1003
1259
  interrupted() +
1004
1260
  renamed +
1005
- evicted, { ...body(id, d, sub.watching), watch: wire, ...extras });
1261
+ evicted, { ...body(id, d, sub, sub.watching), watch: wire, ...extras });
1006
1262
  }
1007
1263
  return said(`${changeLine(last, id)} after ${waited}s` +
1008
1264
  (earlier > 0 ? `, and ${earlier} event${earlier === 1 ? '' : 's'} before it` : '') +
1009
1265
  '.' +
1010
1266
  interrupted() +
1011
1267
  renamed +
1012
- evicted, { ...body(id, d, sub.watching), watch: wire, ...extras });
1268
+ evicted, { ...body(id, d, sub, sub.watching), watch: wire, ...extras });
1013
1269
  }));
1014
1270
  server.registerTool('poll_events', {
1015
1271
  title: 'Read what has happened',
@@ -1036,6 +1292,21 @@ export const registerEvents = (server, session) => {
1036
1292
  // had already taken — the same defect one tool over, with a ring in this
1037
1293
  // session instead of a cursor in the guest. Nothing is created and nothing
1038
1294
  // is destroyed, which is what made the annotation look right.
1295
+ // No readOnlyHint, for the reason above — and BOTH other flags set,
1296
+ // because the spec's defaults are wrong in one direction and unstated in
1297
+ // the other the moment readOnlyHint is absent (OPL-4516).
1298
+ //
1299
+ // destructiveHint would default to TRUE, so without it this asks a host
1300
+ // whether it may perform DESTRUCTIVE UPDATES in order to read.
1301
+ // `cursor_position` and `read_file` set it for that reason; these were not
1302
+ // revisited with them.
1303
+ //
1304
+ // idempotentHint is FALSE, which is the difference from those two, and it
1305
+ // is set rather than left to its default for the same reason as the other:
1306
+ // a host reading an absent flag as "unknown, safe to retry" would retry a
1307
+ // timed-out poll and silently drop what the first attempt consumed, which
1308
+ // is the harm the paragraph above it is about.
1309
+ annotations: { destructiveHint: false, idempotentHint: false },
1039
1310
  }, ({ computer_id, since, limit }, extra) => guarded(async () => {
1040
1311
  const id = session.resolve(computer_id);
1041
1312
  const sub = session.events.open(id);
@@ -1046,15 +1317,34 @@ export const registerEvents = (server, session) => {
1046
1317
  const started = Date.now();
1047
1318
  await attached(sub, extra.signal);
1048
1319
  const state = sub.state;
1320
+ if (extra.signal?.aborted)
1321
+ return cancelledDuringAttach(id);
1049
1322
  if (state.status === 'stopped') {
1050
1323
  return stopped(session, id, state.reason, sub, { since, limit });
1051
1324
  }
1052
- if (extra.signal?.aborted)
1053
- return cancelledDuringAttach(id);
1054
1325
  if (!sub.eventTypes)
1055
1326
  return unattached(id, Date.now() - started);
1327
+ const connection = sub.connected ? sub.connectionVersion : undefined;
1328
+ const needsReconciliation = sub.needsReconciliation({ since, limit });
1329
+ const recovered = needsReconciliation ? await reconcile(session, id, extra.signal) : {};
1330
+ if (extra.signal?.aborted) {
1331
+ return refused(`Cancelled while reading events on ${id}. Buffered events remain available on the next ` +
1332
+ `call. ` +
1333
+ (connectionNote(sub, connection) || 'The stream is still open and buffering.'));
1334
+ }
1335
+ const after = sub.state;
1336
+ if (after.status === 'stopped') {
1337
+ return stopped(session, id, after.reason, sub, { since, limit });
1338
+ }
1056
1339
  const d = sub.read({ since, limit });
1057
- const extras = d.loss ? await reconcile(session, id, extra.signal) : {};
1340
+ const extras = d.loss ? recovered : {};
1341
+ const interruption = connectionNote(sub, connection);
1342
+ if (interruption) {
1343
+ return said(interruption + (d.loss ? reconciled(extras) : ''), {
1344
+ ...body(id, d, sub, sub.watching),
1345
+ ...extras,
1346
+ });
1347
+ }
1058
1348
  if (!d.events.length) {
1059
1349
  // The one case where "this is an answer rather than a gap" is exactly
1060
1350
  // wrong: a gap whose surviving events were all read already leaves an
@@ -1062,16 +1352,16 @@ export const registerEvents = (server, session) => {
1062
1352
  // either way; the sentence has to agree with it.
1063
1353
  if (d.loss) {
1064
1354
  return said(`Nothing new on ${id} that survived — there is a hole in the history here, and what ` +
1065
- `was in it is gone. See lost for what is known about it, and windows_now and ` +
1066
- `computer_now for where the computer actually stands, which is what the missing ` +
1067
- `events would have told you.`, { ...body(id, d, sub.watching), ...extras });
1355
+ `was in it is gone.${reconciled(extras)}`, { ...body(id, d, sub, sub.watching), ...extras });
1068
1356
  }
1069
1357
  return said(`Nothing new on ${id}. The stream is open and buffering, so this is an answer rather ` +
1070
- `than a gap: nothing has been reported since you last read.`, { ...body(id, d, sub.watching), ...extras });
1358
+ `than a gap: nothing has been reported since you last read.`, { ...body(id, d, sub, sub.watching), ...extras });
1071
1359
  }
1072
1360
  const kinds = [...new Set(d.events.map((e) => e.type))].join(', ');
1073
1361
  return said(`${d.events.length} event${d.events.length === 1 ? '' : 's'} on ${id}: ${kinds}.` +
1074
- (d.more ? ` ${d.more} more are buffered — call again for them.` : ''), { ...body(id, d, sub.watching), ...extras });
1362
+ (d.more
1363
+ ? ` ${d.more} more ${d.more === 1 ? 'is' : 'are'} buffered — call again for ${d.more === 1 ? 'it' : 'them'}.`
1364
+ : ''), { ...body(id, d, sub, sub.watching), ...extras });
1075
1365
  }));
1076
1366
  };
1077
1367
  //# sourceMappingURL=events.js.map