mandala-computer-mcp 0.1.1 → 0.3.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 (66) hide show
  1. package/README.md +127 -16
  2. package/dist/api.d.ts +19 -6
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +261 -57
  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 +74 -9
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/errors.js +115 -25
  12. package/dist/errors.js.map +1 -1
  13. package/dist/events.d.ts +36 -4
  14. package/dist/events.d.ts.map +1 -1
  15. package/dist/events.js +223 -33
  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 +31 -1
  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/paths.d.ts +28 -18
  33. package/dist/paths.d.ts.map +1 -1
  34. package/dist/paths.js +85 -22
  35. package/dist/paths.js.map +1 -1
  36. package/dist/poll.d.ts +103 -0
  37. package/dist/poll.d.ts.map +1 -0
  38. package/dist/poll.js +129 -0
  39. package/dist/poll.js.map +1 -0
  40. package/dist/server.d.ts +1 -1
  41. package/dist/server.js +1 -1
  42. package/dist/tools/agent.d.ts.map +1 -1
  43. package/dist/tools/agent.js +14 -2
  44. package/dist/tools/agent.js.map +1 -1
  45. package/dist/tools/computers.d.ts.map +1 -1
  46. package/dist/tools/computers.js +346 -62
  47. package/dist/tools/computers.js.map +1 -1
  48. package/dist/tools/events.d.ts.map +1 -1
  49. package/dist/tools/events.js +295 -49
  50. package/dist/tools/events.js.map +1 -1
  51. package/dist/tools/guest.d.ts.map +1 -1
  52. package/dist/tools/guest.js +228 -32
  53. package/dist/tools/guest.js.map +1 -1
  54. package/dist/tools/input.d.ts.map +1 -1
  55. package/dist/tools/input.js +29 -3
  56. package/dist/tools/input.js.map +1 -1
  57. package/dist/tools/snapshots.d.ts.map +1 -1
  58. package/dist/tools/snapshots.js +478 -20
  59. package/dist/tools/snapshots.js.map +1 -1
  60. package/dist/tools/templates.d.ts.map +1 -1
  61. package/dist/tools/templates.js +52 -13
  62. package/dist/tools/templates.js.map +1 -1
  63. package/dist/tools/webhooks.d.ts.map +1 -1
  64. package/dist/tools/webhooks.js +116 -17
  65. package/dist/tools/webhooks.js.map +1 -1
  66. 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);
@@ -382,6 +488,7 @@ export const registerEvents = (server, session) => {
382
488
  return cancelledDuringAttach(id);
383
489
  if (!sub.eventTypes)
384
490
  return unattached(id, Date.now() - started);
491
+ const connection = sub.connected ? sub.connectionVersion : undefined;
385
492
  // A `pid` on its own means the exit of THAT command. Without this the
386
493
  // filter below reads "anything that is not a process.exited passes",
387
494
  // and `wait_for_event({pid: 99})` — which is how the argument's own
@@ -504,8 +611,8 @@ export const registerEvents = (server, session) => {
504
611
  const waited = Math.round((Date.now() - started) / 1000);
505
612
  if (hit === undefined) {
506
613
  if (extra.signal?.aborted) {
507
- return refused(`Cancelled while waiting on ${id}. The stream is still open and still buffering — ` +
508
- `nothing was missed.`);
614
+ return refused(`Cancelled while waiting on ${id}. Buffered events remain available on the next call. ` +
615
+ (connectionNote(sub, connection) || 'The stream is still open and buffering.'));
509
616
  }
510
617
  const now = sub.state;
511
618
  if (now.status === 'stopped')
@@ -526,7 +633,7 @@ export const registerEvents = (server, session) => {
526
633
  // again cost nothing. Reporting it as a failure would teach a model to
527
634
  // stop asking — back to the screenshot loop this tool exists to end.
528
635
  const d = sub.read({ since, limit });
529
- const extras = d.loss ? await reconcile(session, id, extra.signal) : {};
636
+ const extras = d.loss ? await reconcile(session, id, extra.signal, deadline) : {};
530
637
  // What did NOT match still happened, and this read has just handed it
531
638
  // over — so the sentence has to name it. Saying "nothing happened"
532
639
  // over a payload holding three events is the one thing a model must
@@ -537,6 +644,11 @@ export const registerEvents = (server, session) => {
537
644
  ? ` ${d.events.length} other event${d.events.length === 1 ? '' : 's'} did happen and ` +
538
645
  `${d.events.length === 1 ? 'is' : 'are'} below.`
539
646
  : '';
647
+ const interrupted = connectionNote(sub, connection);
648
+ if (interrupted || d.loss) {
649
+ return said(`${interrupted || `No matching event is buffered on ${id}.`}${others}` +
650
+ (d.loss ? reconciled(extras) : ''), { ...body(id, d, sub, sub.watching), ...extras });
651
+ }
540
652
  return said(`Nothing ${wanted ? `matching ${[...wanted].join(' or ')} ` : ''}happened on ${id} in ` +
541
653
  `${timeout_s}s.${others} This server kept listening the whole time and is still ` +
542
654
  `listening — nothing was missed and nothing is being missed now. Call again to ` +
@@ -545,11 +657,11 @@ export const registerEvents = (server, session) => {
545
657
  // wait was real: what must not happen is a model reading "nothing
546
658
  // happened" as covering a type nothing was ever going to send.
547
659
  (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 });
660
+ (d.loss ? ' Some events were lost before they could be read — see lost.' : ''), { ...body(id, d, sub, sub.watching), ...extras });
549
661
  }
550
662
  const d = sub.read({ since, limit, through: hit });
551
663
  const last = d.events[d.events.length - 1];
552
- const extras = d.loss ? await reconcile(session, id, extra.signal) : {};
664
+ const extras = d.loss ? await reconcile(session, id, extra.signal, deadline) : {};
553
665
  const before = d.events.length - 1;
554
666
  // Empty when another call on this computer consumed the matched event
555
667
  // first: the ring is one buffer with one delivered cursor, and two
@@ -558,7 +670,7 @@ export const registerEvents = (server, session) => {
558
670
  if (!last) {
559
671
  return said(`Something happened on ${id} and another call on this computer was handed it before ` +
560
672
  `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 });
673
+ `is lost; look there, or call again for whatever comes next.`, { ...body(id, d, sub, sub.watching), ...extras });
562
674
  }
563
675
  return said(`${name(last)} on ${id} after ${waited}s` +
564
676
  (before > 0 ? `, and ${before} before it` : '') +
@@ -568,7 +680,7 @@ export const registerEvents = (server, session) => {
568
680
  'and computer.ready is announced once per desktop session — so the real event had ' +
569
681
  'happened before there was anything here to hear it, and waiting for it would have ' +
570
682
  'waited forever.'
571
- : ''), { ...body(id, d, sub.watching), ...extras });
683
+ : ''), { ...body(id, d, sub, sub.watching), ...extras });
572
684
  }));
573
685
  /**
574
686
  * What one `file.changed` says, in a sentence.
@@ -691,6 +803,21 @@ export const registerEvents = (server, session) => {
691
803
  // tree out of the watch set. A client treating the hint as licence to
692
804
  // retry a call that timed out would silently drop events and, on the
693
805
  // fifth distinct path, silently stop watching the first.
806
+ // No readOnlyHint, for the two reasons above — and destructiveHint TRUE,
807
+ // which is where this parts company with the three consuming reads beside
808
+ // it (OPL-4516).
809
+ //
810
+ // The spec reads `false` as "performs only additive updates", and this does
811
+ // not: a nomination past MAX_WATCHES evicts the least recent tree and
812
+ // deletes its armed, lost and host state, so a fifth call ends event
813
+ // delivery on the first with nothing said. Claiming additive-only would be
814
+ // the same false all-clear the missing readOnlyHint already avoids — a host
815
+ // that auto-approves non-destructive tools would let the model do it with
816
+ // nobody asked. Same value the absent annotation defaulted to, said out
817
+ // loud so it reads as a decision rather than an omission.
818
+ //
819
+ // idempotentHint false for the reason the others set it: this consumes.
820
+ annotations: { destructiveHint: true, idempotentHint: false },
694
821
  }, ({ computer_id, path, timeout_s, since, limit }, extra) => guarded(async () => {
695
822
  const id = session.resolve(computer_id);
696
823
  // Before anything is opened. A path this host will not accept is a 400
@@ -777,7 +904,7 @@ export const registerEvents = (server, session) => {
777
904
  if (sub.watchesHonoured === false) {
778
905
  return refused(`${id} opened its event stream but said nothing about ${root}, so this server cannot ` +
779
906
  `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 ` +
907
+ `host holding this computer may predate file watches (OPL-3927). Use exec ` +
781
908
  `to look at the directory instead.` +
782
909
  evicted);
783
910
  }
@@ -817,6 +944,7 @@ export const registerEvents = (server, session) => {
817
944
  // whatever happened in between was never reported and the tree has to
818
945
  // be re-read.
819
946
  const generation = sub.armGeneration(root);
947
+ const connection = sub.connected ? sub.connectionVersion : undefined;
820
948
  // Said once, on the first answer about a tree this subscription
821
949
  // inherited from one that went away. A tree is watched by the
822
950
  // CONNECTION, so the idle reap that took the previous subscription also
@@ -839,6 +967,49 @@ export const registerEvents = (server, session) => {
839
967
  `a watch lives on the connection. Anything that changed in that window was never ` +
840
968
  `reported and cannot be. Re-read the directory with exec if it matters.`
841
969
  : '';
970
+ // Kept by the subscription rather than inferred from this call's
971
+ // generation snapshot. A re-arm can arrive while no tool call exists,
972
+ // and snapshotting after it would otherwise erase the only evidence of
973
+ // the unreported window. Delete the marker here, where its explanation
974
+ // is rendered, so unrelated reads and cancelled waits cannot consume it.
975
+ const rearmNotice = () => `The watch on ${wire} was re-armed after an interruption — a stop and a start, a ` +
976
+ `guest reboot, a broker replaced. Reporting starts again HERE, and nothing that ` +
977
+ `happened to the tree while it was down was reported or ever will be. Re-read the ` +
978
+ `directory with exec if that window matters, then call again to keep waiting.`;
979
+ const cancelledWait = () => refused(`Cancelled while waiting on ${wire}. Nothing was missed by the cancellation — this ` +
980
+ `server holds the stream and its buffer between calls — but nothing was checked ` +
981
+ `about the watch either, so call again for an answer about the tree.` +
982
+ interrupted());
983
+ if (sub.hasUndisclosedRearm(root)) {
984
+ const needsReconciliation = sub.needsReconciliation({ since, limit });
985
+ const recovered = needsReconciliation
986
+ ? await reconcile(session, id, extra.signal, deadline)
987
+ : {};
988
+ if (extra.signal?.aborted)
989
+ return cancelledWait();
990
+ const after = sub.state;
991
+ if (after.status === 'stopped')
992
+ return stopped(session, id, after.reason, sub, { since, limit });
993
+ const d = sub.read({ since, limit });
994
+ const extras = d.loss ? recovered : {};
995
+ const answer = { ...body(id, d, sub, sub.watching), watch: wire, ...extras };
996
+ const why = settled(sub, id, root, wire, () => interrupted() + evicted, answer);
997
+ if (why)
998
+ return why;
999
+ // A competing same-tree wait may have explained it while reconcile
1000
+ // was in flight. Only the call that claims the marker repeats it.
1001
+ if (sub.takeUndisclosedRearm(root)) {
1002
+ return said(rearmNotice() + interrupted() + renamed + evicted, answer);
1003
+ }
1004
+ return said(`Another call on ${id} reported the watch interruption while this call was ` +
1005
+ `reconciling it. This call's delivery is below.` +
1006
+ (d.loss
1007
+ ? ` Some buffered history was lost before it could be read.${reconciled(extras)}`
1008
+ : ` No buffered event was dropped.`) +
1009
+ interrupted() +
1010
+ renamed +
1011
+ evicted, answer);
1012
+ }
842
1013
  // When the waiting actually started, which is not when the call did.
843
1014
  // `timeout_s` bounds the whole call — it has to, because a client
844
1015
  // cancels a request that outlives its own timeout — so a call that
@@ -892,17 +1063,23 @@ export const registerEvents = (server, session) => {
892
1063
  // call did not check and which a cancel racing an eviction or a
893
1064
  // shed makes false. What IS true is the part that matters: the
894
1065
  // 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());
1066
+ return cancelledWait();
899
1067
  }
900
1068
  const now = sub.state;
901
1069
  if (now.status === 'stopped')
902
1070
  return stopped(session, id, now.reason, sub, { since, limit });
1071
+ const needsReconciliation = sub.needsReconciliation({ since, limit });
1072
+ const recovered = needsReconciliation
1073
+ ? await reconcile(session, id, extra.signal, deadline)
1074
+ : {};
1075
+ if (extra.signal?.aborted)
1076
+ return cancelledWait();
1077
+ const after = sub.state;
1078
+ if (after.status === 'stopped')
1079
+ return stopped(session, id, after.reason, sub, { since, limit });
903
1080
  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 };
1081
+ const extras = d.loss ? recovered : {};
1082
+ const answer = { ...body(id, d, sub, sub.watching), watch: wire, ...extras };
906
1083
  // THESE FIRST, and in this order, because each would otherwise be
907
1084
  // described as something else. Evicting a tree does not reset its arm
908
1085
  // generation — that is kept monotonic on purpose — but it does take
@@ -912,18 +1089,14 @@ export const registerEvents = (server, session) => {
912
1089
  // the set for a quite different reason, which is why the refusal is
913
1090
  // asked about ahead of the membership.
914
1091
  const why = settled(sub, id, root, wire, () => interrupted() + evicted, {
915
- ...body(id, d, sub.watching),
1092
+ ...body(id, d, sub, sub.watching),
916
1093
  ...extras,
917
1094
  });
918
1095
  if (why)
919
1096
  return why;
920
1097
  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);
1098
+ sub.takeUndisclosedRearm(root);
1099
+ return said(rearmNotice() + interrupted() + evicted, answer);
927
1100
  }
928
1101
  if (!sub.isArmed(root)) {
929
1102
  return refused(`${wire} on ${id} stopped being watched while this call was waiting, so NOTHING can ` +
@@ -959,6 +1132,15 @@ export const registerEvents = (server, session) => {
959
1132
  interrupted() +
960
1133
  evicted, answer);
961
1134
  }
1135
+ const connectionChanged = connectionNote(sub, connection);
1136
+ if (connectionChanged || d.loss) {
1137
+ return said(`${connectionChanged || `No file change is buffered for ${wire}.`} ` +
1138
+ `A quiet directory cannot be confirmed for this whole wait.` +
1139
+ (d.loss ? reconciled(extras) : '') +
1140
+ interrupted() +
1141
+ renamed +
1142
+ evicted, answer);
1143
+ }
962
1144
  // NOT an error, for the reason wait_for_event's timeout is not: the
963
1145
  // tree was being watched for the whole of it, so "nothing changed" is
964
1146
  // an answer rather than an absence of one. This is the sentence the
@@ -977,19 +1159,50 @@ export const registerEvents = (server, session) => {
977
1159
  evicted +
978
1160
  (d.loss ? ' Some events were lost before they could be read — see lost.' : ''), answer);
979
1161
  }
1162
+ const needsReconciliation = sub.needsReconciliation({ since, limit, through: hit });
1163
+ const recovered = needsReconciliation
1164
+ ? await reconcile(session, id, extra.signal, deadline)
1165
+ : {};
1166
+ if (extra.signal?.aborted)
1167
+ return cancelledWait();
980
1168
  const d = sub.read({ since, limit, through: hit });
981
1169
  const last = d.events[d.events.length - 1];
982
- const extras = d.loss ? await reconcile(session, id, extra.signal) : {};
1170
+ const extras = d.loss ? recovered : {};
983
1171
  const earlier = d.events.length - 1;
984
1172
  // The one `lost` that is not a re-read: it says the tree is not being
985
1173
  // watched, so it is the request failing rather than the watch reporting.
986
1174
  // It arrives as an ordinary file.changed and would otherwise be
987
1175
  // described by changeLine, which has nothing useful to say about it.
988
1176
  if ((last?.data?.lost ?? '') === 'unwatchable') {
989
- return refused(`${unwatchable(wire)} It was being watched until now; from here it is not.` +
1177
+ // Only when the tree is not being watched NOW. An `unwatchable` sits
1178
+ // in the ring until something reads it, and a caller who created the
1179
+ // directory and called again re-nominates: the guest answers the new
1180
+ // nomination with an arm, and this marker — already reported to the
1181
+ // call that refused on it — is then describing a watch that has since
1182
+ // been replaced. Handed back as the answer it refuses a second time
1183
+ // over a directory that plainly exists, which is the same false
1184
+ // "not there yet" the re-nomination exists to clear, one call later.
1185
+ //
1186
+ // `isArmed` is what tells the two apart, and it is exact rather than a
1187
+ // proxy: `unwatchable` is the one `lost` that disarms, so a tree still
1188
+ // carrying this marker as its live state reads false here, while one
1189
+ // the guest has since armed reads true. Said with the re-arm's own
1190
+ // sentence, because that is what happened and the window is genuinely
1191
+ // unreported either way.
1192
+ if (!sub.isArmed(root)) {
1193
+ return refused(`${unwatchable(wire)} It was being watched until now; from here it is not.` +
1194
+ interrupted() +
1195
+ renamed +
1196
+ evicted, { ...body(id, d, sub, sub.watching), watch: wire, ...extras });
1197
+ }
1198
+ return said(`${wire} on ${id} was not watchable when this stream last asked — it was reported ` +
1199
+ `missing or unreadable — and it is being watched now: the nomination was retried and ` +
1200
+ `the guest armed it. Reporting starts HERE, so nothing that happened under it before ` +
1201
+ `this was reported or ever will be. Re-read the directory with exec if that window ` +
1202
+ `matters, then call again to wait for what comes next.` +
990
1203
  interrupted() +
991
1204
  renamed +
992
- evicted, { ...body(id, d, sub.watching), watch: wire, ...extras });
1205
+ evicted, { ...body(id, d, sub, sub.watching), watch: wire, ...extras });
993
1206
  }
994
1207
  // A `through` read can come back empty when another call on this
995
1208
  // computer consumed the matched event first — the ring is one buffer
@@ -997,19 +1210,29 @@ export const registerEvents = (server, session) => {
997
1210
  // before either reads. Announcing "a change" over an empty list would be
998
1211
  // a change the caller is never shown.
999
1212
  if (!last) {
1213
+ if (d.loss) {
1214
+ return said(`A file change under ${wire} on ${id} matched this wait, but the matching event is ` +
1215
+ `no longer in this call's delivery. Some buffered history was lost before it ` +
1216
+ `could be read, so do not assume another reader safely received the match.` +
1217
+ reconciled(extras) +
1218
+ ` Re-read the directory with exec, then call again for whatever comes next.` +
1219
+ interrupted() +
1220
+ renamed +
1221
+ evicted, { ...body(id, d, sub, sub.watching), watch: wire, ...extras });
1222
+ }
1000
1223
  return said(`Something changed under ${wire} on ${id}, and another call on this computer was ` +
1001
1224
  `handed it before this one could read it — the events are in that call's answer, ` +
1002
1225
  `not below. Nothing is lost; look there, or call again for whatever comes next.` +
1003
1226
  interrupted() +
1004
1227
  renamed +
1005
- evicted, { ...body(id, d, sub.watching), watch: wire, ...extras });
1228
+ evicted, { ...body(id, d, sub, sub.watching), watch: wire, ...extras });
1006
1229
  }
1007
1230
  return said(`${changeLine(last, id)} after ${waited}s` +
1008
1231
  (earlier > 0 ? `, and ${earlier} event${earlier === 1 ? '' : 's'} before it` : '') +
1009
1232
  '.' +
1010
1233
  interrupted() +
1011
1234
  renamed +
1012
- evicted, { ...body(id, d, sub.watching), watch: wire, ...extras });
1235
+ evicted, { ...body(id, d, sub, sub.watching), watch: wire, ...extras });
1013
1236
  }));
1014
1237
  server.registerTool('poll_events', {
1015
1238
  title: 'Read what has happened',
@@ -1036,6 +1259,21 @@ export const registerEvents = (server, session) => {
1036
1259
  // had already taken — the same defect one tool over, with a ring in this
1037
1260
  // session instead of a cursor in the guest. Nothing is created and nothing
1038
1261
  // is destroyed, which is what made the annotation look right.
1262
+ // No readOnlyHint, for the reason above — and BOTH other flags set,
1263
+ // because the spec's defaults are wrong in one direction and unstated in
1264
+ // the other the moment readOnlyHint is absent (OPL-4516).
1265
+ //
1266
+ // destructiveHint would default to TRUE, so without it this asks a host
1267
+ // whether it may perform DESTRUCTIVE UPDATES in order to read.
1268
+ // `cursor_position` and `read_file` set it for that reason; these were not
1269
+ // revisited with them.
1270
+ //
1271
+ // idempotentHint is FALSE, which is the difference from those two, and it
1272
+ // is set rather than left to its default for the same reason as the other:
1273
+ // a host reading an absent flag as "unknown, safe to retry" would retry a
1274
+ // timed-out poll and silently drop what the first attempt consumed, which
1275
+ // is the harm the paragraph above it is about.
1276
+ annotations: { destructiveHint: false, idempotentHint: false },
1039
1277
  }, ({ computer_id, since, limit }, extra) => guarded(async () => {
1040
1278
  const id = session.resolve(computer_id);
1041
1279
  const sub = session.events.open(id);
@@ -1053,8 +1291,16 @@ export const registerEvents = (server, session) => {
1053
1291
  return cancelledDuringAttach(id);
1054
1292
  if (!sub.eventTypes)
1055
1293
  return unattached(id, Date.now() - started);
1294
+ const connection = sub.connected ? sub.connectionVersion : undefined;
1056
1295
  const d = sub.read({ since, limit });
1057
1296
  const extras = d.loss ? await reconcile(session, id, extra.signal) : {};
1297
+ const interruption = connectionNote(sub, connection);
1298
+ if (interruption) {
1299
+ return said(interruption + (d.loss ? reconciled(extras) : ''), {
1300
+ ...body(id, d, sub, sub.watching),
1301
+ ...extras,
1302
+ });
1303
+ }
1058
1304
  if (!d.events.length) {
1059
1305
  // The one case where "this is an answer rather than a gap" is exactly
1060
1306
  // wrong: a gap whose surviving events were all read already leaves an
@@ -1062,16 +1308,16 @@ export const registerEvents = (server, session) => {
1062
1308
  // either way; the sentence has to agree with it.
1063
1309
  if (d.loss) {
1064
1310
  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 });
1311
+ `was in it is gone.${reconciled(extras)}`, { ...body(id, d, sub, sub.watching), ...extras });
1068
1312
  }
1069
1313
  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 });
1314
+ `than a gap: nothing has been reported since you last read.`, { ...body(id, d, sub, sub.watching), ...extras });
1071
1315
  }
1072
1316
  const kinds = [...new Set(d.events.map((e) => e.type))].join(', ');
1073
1317
  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 });
1318
+ (d.more
1319
+ ? ` ${d.more} more ${d.more === 1 ? 'is' : 'are'} buffered — call again for ${d.more === 1 ? 'it' : 'them'}.`
1320
+ : ''), { ...body(id, d, sub, sub.watching), ...extras });
1075
1321
  }));
1076
1322
  };
1077
1323
  //# sourceMappingURL=events.js.map