weir-harness 2.1.1 → 2.1.2

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.
@@ -7,7 +7,8 @@
7
7
  // notices for one release — and would let one mount's teardown cancel a wait
8
8
  // another mount still owns.
9
9
  //
10
- // The coordinator holds three collections and nothing else:
10
+ // The coordinator holds three collections, plus a WeakMap of private episode
11
+ // records keyed by the stable opaque handles handed to callers:
11
12
  //
12
13
  // active one entry per `(session, normalized path)`, in `watching`
13
14
  // pendingDelivery consumed entries (`hit` / `reset`) whose notice has not
@@ -46,16 +47,54 @@ export const WATCH_STATES = Object.freeze(['watching', 'hit', 'reset', 'self', '
46
47
  */
47
48
 
48
49
  /**
50
+ * The stable opaque handle returned to callers. Its four public fields are
51
+ * live, getter-only views; all mutable state lives in the coordinator's WeakMap.
52
+ *
49
53
  * @typedef {object} WatchEntry
50
54
  * @property {string} sessionId the session that subscribed
51
55
  * @property {string} path normalized absolute path
52
56
  * @property {WatchState} state
53
57
  * @property {boolean} settledByTool true once a running tool already reported
54
58
  * this episode to the model, so a deferred notice would only repeat it
59
+ */
60
+
61
+ /**
62
+ * The coordinator's structured establishment verdict. Model-facing wording and
63
+ * presentation metadata stay in tools.js; this vocabulary describes decisions.
64
+ *
65
+ * @typedef {object} WatchOutcome
66
+ * @property {'watching' | 'free' | 'self' | 'released' | 'reset' | 'unsupported' | 'cancelled' | 'transport'} state
67
+ * @property {any} [holder] the current foreign holder for a watching outcome
68
+ * @property {unknown} [error] the transport failure for a transport outcome
69
+ * @property {boolean} [raced] ownership arrived asynchronously while establishing
70
+ */
71
+
72
+ /**
73
+ * The lease-specific facts the coordinator needs to classify a status reply.
74
+ * The read and capability query remain injected, so this module has no socket.
75
+ *
76
+ * @typedef {object} WatchStatusOptions
77
+ * @property {{ id: string, session: string }} owner the subscribing identity
78
+ * @property {() => boolean} supportsRelease whether the CURRENT connection
79
+ * announces released events
80
+ */
81
+
82
+ /**
83
+ * The private mutable record behind one WatchEntry handle.
84
+ *
85
+ * @typedef {object} WatchEpisodeRecord
86
+ * @property {string} sessionId the session that subscribed
87
+ * @property {string} path normalized absolute path
88
+ * @property {WatchState} state
89
+ * @property {boolean} settledByTool true once a running tool already reported
90
+ * this episode to the model, so a deferred notice would only repeat it
55
91
  * @property {any} agent the agent that subscribed: delivery re-resolves the
56
92
  * session and refuses to land on a replacement agent
57
93
  * @property {Promise<any> | null} statusFlight the ONE authoritative read this
58
94
  * entry is currently sharing, so overlapping calls cannot disagree
95
+ * @property {Promise<WatchOutcome> | null} establishFlight the ONE complete
96
+ * establishment attempt shared by overlapping callers; cleared after watching
97
+ * so a later call performs a fresh authoritative read
59
98
  * @property {Set<WatchMount>} mounts the mount references that still want it
60
99
  * @property {ReturnType<typeof setTimeout> | null} timer the deferred notice
61
100
  */
@@ -84,6 +123,11 @@ function unrefTimer(timer) {
84
123
  if (typeof handle.unref === 'function') handle.unref()
85
124
  }
86
125
 
126
+ /** @param {unknown} error @returns {boolean} */
127
+ function isTransport(error) {
128
+ return error !== null && typeof error === 'object' && /** @type {any} */ (error).code === 'transport'
129
+ }
130
+
87
131
  /**
88
132
  * The coordinator surface: one factory is the single definition of what a
89
133
  * consumer holds, so the tool surface and the plugin row never re-describe it.
@@ -96,12 +140,25 @@ export function createWatchCoordinator() {
96
140
  const pendingDelivery = new Set()
97
141
  /** @type {Set<WatchMount>} */
98
142
  const mounts = new Set()
143
+ /** @type {WeakMap<WatchEntry, WatchEpisodeRecord>} */
144
+ const records = new WeakMap()
99
145
 
100
146
  /** @param {string} sessionId @param {string} path @returns {string} */
101
147
  function keyOf(sessionId, path) {
102
148
  return `${sessionId}\u0000${path}`
103
149
  }
104
150
 
151
+ /**
152
+ * Resolve the private record without exposing it on the stable public handle.
153
+ * @param {WatchEntry} entry
154
+ * @returns {WatchEpisodeRecord}
155
+ */
156
+ function recordOf(entry) {
157
+ const record = records.get(entry)
158
+ if (record === undefined) throw new TypeError('Unknown watch episode handle')
159
+ return record
160
+ }
161
+
105
162
  /**
106
163
  * The public shape of an entry. Internals (mounts, flight, timer, agent
107
164
  * identity) stay inside the coordinator.
@@ -109,7 +166,8 @@ export function createWatchCoordinator() {
109
166
  * @returns {WatchProjection}
110
167
  */
111
168
  function project(entry) {
112
- return { sessionId: entry.sessionId, path: entry.path, state: entry.state, settledByTool: entry.settledByTool }
169
+ const record = recordOf(entry)
170
+ return { sessionId: record.sessionId, path: record.path, state: record.state, settledByTool: record.settledByTool }
113
171
  }
114
172
 
115
173
  /**
@@ -120,16 +178,18 @@ export function createWatchCoordinator() {
120
178
  * @returns {WatchMount | undefined}
121
179
  */
122
180
  function liveMount(entry) {
123
- for (const mount of entry.mounts) if (!mount.disposed) return mount
181
+ const record = recordOf(entry)
182
+ for (const mount of record.mounts) if (!mount.disposed) return mount
124
183
  for (const mount of mounts) if (!mount.disposed) return mount
125
184
  return undefined
126
185
  }
127
186
 
128
187
  /** @param {WatchEntry} entry */
129
188
  function cancelTimer(entry) {
130
- if (entry.timer === null) return
131
- clearTimeout(entry.timer)
132
- entry.timer = null
189
+ const record = recordOf(entry)
190
+ if (record.timer === null) return
191
+ clearTimeout(record.timer)
192
+ record.timer = null
133
193
  }
134
194
 
135
195
  /**
@@ -146,7 +206,7 @@ export function createWatchCoordinator() {
146
206
  */
147
207
  function scheduleNotice(entry) {
148
208
  pendingDelivery.add(entry)
149
- const flight = entry.statusFlight
209
+ const flight = recordOf(entry).statusFlight
150
210
  if (flight === null) {
151
211
  armNotice(entry)
152
212
  return
@@ -172,28 +232,29 @@ export function createWatchCoordinator() {
172
232
  */
173
233
  function armNotice(entry) {
174
234
  if (!pendingDelivery.has(entry)) return
175
- if (entry.state !== 'hit' && entry.state !== 'reset') {
235
+ const record = recordOf(entry)
236
+ if (record.state !== 'hit' && record.state !== 'reset') {
176
237
  pendingDelivery.delete(entry)
177
238
  return
178
239
  }
179
- if (entry.timer !== null) return
240
+ if (record.timer !== null) return
180
241
  const timer = setTimeout(() => {
181
- entry.timer = null
242
+ record.timer = null
182
243
  pendingDelivery.delete(entry)
183
- if (entry.state !== 'hit' && entry.state !== 'reset') return
184
- if (entry.settledByTool === true) return
244
+ if (record.state !== 'hit' && record.state !== 'reset') return
245
+ if (record.settledByTool === true) return
185
246
  const mount = liveMount(entry)
186
247
  if (mount === undefined) return
187
- const agent = mount.agentFor(entry.sessionId)
248
+ const agent = mount.agentFor(record.sessionId)
188
249
  if (agent === undefined || agent === null) return
189
- if (entry.agent !== undefined && agent !== entry.agent) return
250
+ if (record.agent !== undefined && agent !== record.agent) return
190
251
  try {
191
252
  mount.notify(entry, agent)
192
253
  } catch {
193
254
  // A notice is best effort: delivery must never break the transport.
194
255
  }
195
256
  }, 0)
196
- entry.timer = timer
257
+ record.timer = timer
197
258
  unrefTimer(timer)
198
259
  }
199
260
 
@@ -204,7 +265,7 @@ export function createWatchCoordinator() {
204
265
  */
205
266
  function endPending(entry, state) {
206
267
  if (pendingDelivery.delete(entry)) cancelTimer(entry)
207
- entry.state = state
268
+ recordOf(entry).state = state
208
269
  }
209
270
 
210
271
  /**
@@ -229,12 +290,13 @@ export function createWatchCoordinator() {
229
290
  if (!mounts.delete(mount)) return
230
291
  mount.disposed = true
231
292
  for (const [key, entry] of [...active]) {
232
- if (!entry.mounts.delete(mount)) continue
233
- if (entry.mounts.size > 0) continue
293
+ const record = recordOf(entry)
294
+ if (!record.mounts.delete(mount)) continue
295
+ if (record.mounts.size > 0) continue
234
296
  active.delete(key)
235
- entry.state = 'cancelled'
297
+ record.state = 'cancelled'
236
298
  }
237
- for (const entry of [...pendingDelivery]) entry.mounts.delete(mount)
299
+ for (const entry of [...pendingDelivery]) recordOf(entry).mounts.delete(mount)
238
300
  if (mounts.size === 0) clear()
239
301
  }
240
302
 
@@ -253,22 +315,31 @@ export function createWatchCoordinator() {
253
315
  const key = keyOf(sessionId, path)
254
316
  const known = active.get(key)
255
317
  if (known !== undefined) {
256
- if (mount !== undefined) known.mounts.add(mount)
257
- if (agent !== undefined && agent !== null) known.agent = agent
318
+ const record = recordOf(known)
319
+ if (mount !== undefined) record.mounts.add(mount)
320
+ if (agent !== undefined && agent !== null) record.agent = agent
258
321
  return known
259
322
  }
260
- /** @type {WatchEntry} */
261
- const entry = {
323
+ /** @type {WatchEpisodeRecord} */
324
+ const record = {
262
325
  sessionId,
263
326
  path,
264
327
  state: 'watching',
265
328
  settledByTool: false,
266
329
  agent,
267
330
  statusFlight: null,
331
+ establishFlight: null,
268
332
  mounts: new Set(),
269
333
  timer: null,
270
334
  }
271
- if (mount !== undefined) entry.mounts.add(mount)
335
+ const entry = /** @type {WatchEntry} */ (Object.freeze({
336
+ get sessionId() { return record.sessionId },
337
+ get path() { return record.path },
338
+ get state() { return record.state },
339
+ get settledByTool() { return record.settledByTool },
340
+ }))
341
+ records.set(entry, record)
342
+ if (mount !== undefined) record.mounts.add(mount)
272
343
  active.set(key, entry)
273
344
  return entry
274
345
  }
@@ -292,14 +363,127 @@ export function createWatchCoordinator() {
292
363
  * @returns {Promise<any>}
293
364
  */
294
365
  function shareStatus(entry, run) {
295
- if (entry.statusFlight !== null) return entry.statusFlight
366
+ const record = recordOf(entry)
367
+ if (record.statusFlight !== null) return record.statusFlight
296
368
  /** @type {Promise<any>} */
297
369
  const flight = Promise.resolve()
298
370
  .then(run)
299
371
  .finally(() => {
300
- if (entry.statusFlight === flight) entry.statusFlight = null
372
+ if (record.statusFlight === flight) record.statusFlight = null
301
373
  })
302
- entry.statusFlight = flight
374
+ record.statusFlight = flight
375
+ return flight
376
+ }
377
+
378
+ /** Record that the running tool reported this episode to the model.
379
+ * @param {WatchEntry} entry
380
+ */
381
+ function report(entry) {
382
+ recordOf(entry).settledByTool = true
383
+ }
384
+
385
+ /**
386
+ * Reconcile one opaque entry at an await boundary. Terminal facts win over
387
+ * whatever the authoritative read now says, and `watching` is reserved for
388
+ * the SAME handle that is still active. Callers never re-implement either
389
+ * identity rule or mark terminal episodes themselves.
390
+ *
391
+ * @param {WatchEntry} entry
392
+ * @returns {WatchOutcome}
393
+ */
394
+ function reconcile(entry) {
395
+ const record = recordOf(entry)
396
+ if (record.state === 'hit') {
397
+ report(entry)
398
+ return { state: 'released' }
399
+ }
400
+ if (record.state === 'reset') {
401
+ report(entry)
402
+ return { state: 'reset' }
403
+ }
404
+ if (record.state === 'self') {
405
+ report(entry)
406
+ return { state: 'self', raced: true }
407
+ }
408
+ if (record.state === 'watching' && active.get(keyOf(record.sessionId, record.path)) === entry) {
409
+ return { state: 'watching' }
410
+ }
411
+ return { state: 'cancelled' }
412
+ }
413
+
414
+ /**
415
+ * Run the complete establishment episode after registration. The read is
416
+ * shared by shareStatus; every continuation reconciles the same entry before
417
+ * trusting a reply or failure. Only a plain, still-active wait may be removed
418
+ * by a late status verdict, and removal is conditional on that exact handle.
419
+ *
420
+ * @param {WatchEntry} entry
421
+ * @param {() => any} run the injected authoritative read
422
+ * @param {WatchStatusOptions} options
423
+ * @returns {Promise<WatchOutcome>}
424
+ */
425
+ async function establish(entry, run, options) {
426
+ const record = recordOf(entry)
427
+ /** @type {any} */
428
+ let reply
429
+ try {
430
+ reply = await shareStatus(entry, run)
431
+ } catch (error) {
432
+ const terminal = reconcile(entry)
433
+ if (terminal.state !== 'watching') return terminal
434
+ cancel(record.sessionId, record.path, entry)
435
+ if (isTransport(error)) return { state: 'transport', error }
436
+ throw error
437
+ }
438
+
439
+ let outcome = reconcile(entry)
440
+ if (outcome.state !== 'watching') return outcome
441
+
442
+ const leases = Array.isArray(reply?.leases) ? reply.leases : []
443
+ if (leases.length === 0) {
444
+ cancel(record.sessionId, record.path, entry)
445
+ return { state: 'free' }
446
+ }
447
+
448
+ const holder = leases[0]?.owner ?? {}
449
+ const mine = (typeof holder.id === 'string' && holder.id.length > 0 && holder.id === options.owner.id)
450
+ || (typeof holder.session === 'string' && holder.session.length > 0 && holder.session === options.owner.session)
451
+ if (mine) {
452
+ cancel(record.sessionId, record.path, entry, 'self')
453
+ return { state: 'self' }
454
+ }
455
+ if (options.supportsRelease() !== true) {
456
+ cancel(record.sessionId, record.path, entry)
457
+ return { state: 'unsupported' }
458
+ }
459
+
460
+ // A capability probe is injectable and may synchronously route an event.
461
+ // Reconcile once more before claiming that this exact wait is still live.
462
+ outcome = reconcile(entry)
463
+ if (outcome.state !== 'watching') return outcome
464
+ return { state: 'watching', holder }
465
+ }
466
+
467
+ /**
468
+ * Share the whole establishment attempt, not merely its read. In particular,
469
+ * two overlapping callers cannot turn one free/unsupported/error verdict into
470
+ * a second contradictory cancelled verdict. A watching attempt is cleared so
471
+ * idempotent later calls still refresh the authority; terminal attempts remain
472
+ * attached only to their private, now-inactive episode record.
473
+ *
474
+ * @param {WatchEntry} entry
475
+ * @param {() => any} run
476
+ * @param {WatchStatusOptions} options
477
+ * @returns {Promise<WatchOutcome>}
478
+ */
479
+ function status(entry, run, options) {
480
+ const record = recordOf(entry)
481
+ if (record.establishFlight !== null) return record.establishFlight
482
+ /** @type {Promise<WatchOutcome>} */
483
+ const flight = establish(entry, run, options).finally(() => {
484
+ if (record.establishFlight === flight && record.state === 'watching') record.establishFlight = null
485
+ })
486
+ record.establishFlight = flight
303
487
  return flight
304
488
  }
305
489
 
@@ -321,7 +505,7 @@ export function createWatchCoordinator() {
321
505
  if (expected !== undefined && entry !== expected) return undefined
322
506
  active.delete(key)
323
507
  if (pendingDelivery.delete(entry)) cancelTimer(entry)
324
- entry.state = state
508
+ recordOf(entry).state = state
325
509
  return entry
326
510
  }
327
511
 
@@ -346,7 +530,7 @@ export function createWatchCoordinator() {
346
530
  const activeEntry = active.get(key)
347
531
  if (activeEntry !== undefined) {
348
532
  active.delete(key)
349
- activeEntry.state = 'self'
533
+ recordOf(activeEntry).state = 'self'
350
534
  ended = ended ?? activeEntry
351
535
  }
352
536
  return ended
@@ -364,9 +548,10 @@ export function createWatchCoordinator() {
364
548
  /** @type {WatchEntry[]} */
365
549
  const consumed = []
366
550
  for (const [key, entry] of [...active]) {
367
- if (entry.path !== path) continue
551
+ const record = recordOf(entry)
552
+ if (record.path !== path) continue
368
553
  active.delete(key)
369
- entry.state = 'hit'
554
+ record.state = 'hit'
370
555
  scheduleNotice(entry)
371
556
  consumed.push(entry)
372
557
  }
@@ -383,7 +568,7 @@ export function createWatchCoordinator() {
383
568
  const entries = [...active.values()]
384
569
  active.clear()
385
570
  for (const entry of entries) {
386
- entry.state = 'reset'
571
+ recordOf(entry).state = 'reset'
387
572
  scheduleNotice(entry)
388
573
  }
389
574
  return entries
@@ -399,9 +584,10 @@ export function createWatchCoordinator() {
399
584
  /** @type {WatchEntry[]} */
400
585
  const cancelled = []
401
586
  for (const [key, entry] of [...active]) {
402
- if (entry.sessionId !== sessionId) continue
587
+ const record = recordOf(entry)
588
+ if (record.sessionId !== sessionId) continue
403
589
  active.delete(key)
404
- entry.state = 'cancelled'
590
+ record.state = 'cancelled'
405
591
  cancelled.push(entry)
406
592
  }
407
593
  for (const entry of [...pendingDelivery]) {
@@ -423,7 +609,7 @@ export function createWatchCoordinator() {
423
609
  active.clear()
424
610
  for (const entry of [...pendingDelivery]) cancelTimer(entry)
425
611
  pendingDelivery.clear()
426
- for (const entry of entries) entry.state = 'cancelled'
612
+ for (const entry of entries) recordOf(entry).state = 'cancelled'
427
613
  return entries
428
614
  }
429
615
 
@@ -458,6 +644,9 @@ export function createWatchCoordinator() {
458
644
  register,
459
645
  peek,
460
646
  shareStatus,
647
+ status,
648
+ reconcile,
649
+ report,
461
650
  cancel,
462
651
  settleSelf,
463
652
  consumePath,