@agentshouse/mdruntime 0.1.0-alpha.3 → 0.1.0-alpha.5

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.
@@ -2,10 +2,13 @@ import { query as evaluate } from '@agentshouse/query';
2
2
  import { ROOM_CONTRACT_VERSION, VERSION_1_CAPABILITIES } from './adapter.js';
3
3
  import { bootstrapRoom } from './bootstrap.js';
4
4
  import { MD_MODEL, PRESENTED } from './collection.js';
5
+ import { createLog } from './log.js';
6
+ import { queuedOutcome, refused } from './outcome.js';
5
7
  import { refreshRoom } from './refresh.js';
6
8
  import { buildBatch } from './save.js';
7
- import { readConfirmed, replicaOf } from './replica.js';
9
+ import { applyReturned, readConfirmed, replicaOf } from './replica.js';
8
10
  import { settled, same, saving, STARTING, transferring } from './status.js';
11
+ import { detectStorageLoss, nextGeneration } from './durability.js';
9
12
  import { openReplicaStore } from './store.js';
10
13
  import { unconfirmed } from './transfer.js';
11
14
  const DEFAULT_DISCOVERY_INTERVAL_MS = 60_000;
@@ -17,10 +20,13 @@ export function createMdRuntime(options) {
17
20
  let store;
18
21
  const replicas = new Map();
19
22
  const drafts = new Map();
23
+ const outcomes = new Map();
20
24
  let limits = [];
25
+ let capabilities = [];
21
26
  const rooms = new Map();
22
27
  const unreadable = new Set();
23
28
  let refusal = null;
29
+ let storageLoss = null;
24
30
  let status = STARTING;
25
31
  let discoveryPaused = false;
26
32
  let timer;
@@ -31,9 +37,19 @@ export function createMdRuntime(options) {
31
37
  let queued = false;
32
38
  const roomListeners = new Set();
33
39
  const refusalListeners = new Set();
40
+ const storageLossListeners = new Set();
34
41
  const statusListeners = new Set();
35
42
  const querySubscriptions = new Set();
36
43
  const queueSubscriptions = new Set();
44
+ const journal = createLog();
45
+ let notifyReady = ()=>{};
46
+ const ready = new Promise((resolve)=>{
47
+ notifyReady = resolve;
48
+ });
49
+ let leader = typeof window === 'undefined';
50
+ let applyingRemote = false;
51
+ let releaseLeadership = ()=>{};
52
+ const channel = typeof window !== 'undefined' ? new BroadcastChannel('mdruntime') : undefined;
37
53
  function at(locator, path) {
38
54
  return `${locator} ${path}`;
39
55
  }
@@ -42,6 +58,30 @@ export function createMdRuntime(options) {
42
58
  ...replicas.values()
43
59
  ].some((replica)=>replica.queue.length > 0);
44
60
  }
61
+ function attention() {
62
+ return [
63
+ ...replicas.values()
64
+ ].some((replica)=>{
65
+ const head = replica.queue[0];
66
+ return head !== undefined && refused(outcomes.get(head.id));
67
+ });
68
+ }
69
+ function storedOutcomes(batches) {
70
+ const stored = {};
71
+ for (const batch of batches){
72
+ const outcome = queuedOutcome(outcomes.get(batch.id));
73
+ if (outcome.kind !== 'pending') stored[batch.id] = outcome;
74
+ }
75
+ return stored;
76
+ }
77
+ async function persistQueue(local, locator, batches) {
78
+ await local.writeQueue({
79
+ locator,
80
+ batches,
81
+ outcomes: storedOutcomes(batches)
82
+ });
83
+ announce();
84
+ }
45
85
  function listed() {
46
86
  return [
47
87
  ...rooms.values()
@@ -63,11 +103,32 @@ export function createMdRuntime(options) {
63
103
  function emitRefusal() {
64
104
  for (const listener of refusalListeners)listener(refusal);
65
105
  }
66
- function moveTo(next) {
106
+ function emitStorageLoss() {
107
+ for (const listener of storageLossListeners)listener(storageLoss);
108
+ }
109
+ function announce(type = 'changed') {
110
+ if (applyingRemote || channel === undefined) return;
111
+ if (type === 'hello') {
112
+ channel.postMessage({
113
+ type: 'hello'
114
+ });
115
+ return;
116
+ }
117
+ channel.postMessage({
118
+ type,
119
+ status
120
+ });
121
+ }
122
+ function adopt(next) {
67
123
  if (closed || same(status, next)) return;
68
124
  status = next;
69
125
  for (const listener of statusListeners)listener(status);
70
126
  }
127
+ function moveTo(next) {
128
+ if (closed || same(status, next)) return;
129
+ adopt(next);
130
+ announce();
131
+ }
71
132
  function visible(request) {
72
133
  return evaluate(queryRooms(), {
73
134
  ...request,
@@ -84,13 +145,18 @@ export function createMdRuntime(options) {
84
145
  subscription.notify(visible(subscription.query));
85
146
  }
86
147
  }
87
- function queueOf(locator) {
88
- return replicas.get(locator)?.queue ?? [];
148
+ function entriesOf(locator) {
149
+ const replica = replicas.get(locator);
150
+ if (replica === undefined) return [];
151
+ return replica.queue.map((batch)=>Object.freeze({
152
+ batch,
153
+ outcome: queuedOutcome(outcomes.get(batch.id))
154
+ }));
89
155
  }
90
156
  function emitQueue(locator) {
91
- const batches = queueOf(locator);
157
+ const entries = entriesOf(locator);
92
158
  for (const subscription of queueSubscriptions){
93
- if (subscription.locator === locator) subscription.notify(batches);
159
+ if (subscription.locator === locator) subscription.notify(entries);
94
160
  }
95
161
  }
96
162
  function stopTimer() {
@@ -111,6 +177,7 @@ export function createMdRuntime(options) {
111
177
  reason,
112
178
  reauthorizationOffered: reason === 'revoked-authorization'
113
179
  };
180
+ journal.record(`connection refused ${reason}`);
114
181
  emitRefusal();
115
182
  }
116
183
  function list(room, synchronizationAvailable) {
@@ -138,8 +205,7 @@ export function createMdRuntime(options) {
138
205
  emitRooms();
139
206
  return listedLocators;
140
207
  }
141
- async function open() {
142
- const opened = await openReplicaStore();
208
+ async function hydrate(opened) {
143
209
  const [records, queues, kept, lastCompleted, declared] = await Promise.all([
144
210
  opened.all(),
145
211
  opened.queues(),
@@ -147,27 +213,94 @@ export function createMdRuntime(options) {
147
213
  opened.lastCompleted(),
148
214
  opened.providerLimits()
149
215
  ]);
150
- store = opened;
151
216
  limits = declared;
217
+ drafts.clear();
152
218
  for (const draft of kept)drafts.set(at(draft.locator, draft.path), draft.content);
153
- const queued = new Map(queues.map((queue)=>[
219
+ const queuedBatches = new Map(queues.map((queue)=>[
154
220
  queue.locator,
155
221
  queue.batches
156
222
  ]));
223
+ outcomes.clear();
224
+ for (const queue of queues){
225
+ for (const [id, outcome] of Object.entries(queue.outcomes)){
226
+ outcomes.set(id, outcome);
227
+ }
228
+ }
229
+ replicas.clear();
157
230
  for (const record of records){
158
231
  const confirmed = readConfirmed(record);
159
232
  if (confirmed === null) continue;
160
- replicas.set(record.locator, replicaOf(confirmed, queued.get(record.locator) ?? []));
161
- list(record, false);
233
+ replicas.set(record.locator, replicaOf(confirmed, queuedBatches.get(record.locator) ?? []));
234
+ const existing = rooms.get(record.locator);
235
+ if (existing === undefined) list(record, false);
236
+ else {
237
+ rooms.set(record.locator, {
238
+ ...existing,
239
+ roomVersion: record.roomVersion
240
+ });
241
+ }
162
242
  }
163
- moveTo({
164
- ...status,
165
- lastCompleted
166
- });
243
+ return {
244
+ lastCompleted,
245
+ locators: new Set([
246
+ ...queuedBatches.keys(),
247
+ ...replicas.keys()
248
+ ])
249
+ };
250
+ }
251
+ async function readState(opened) {
252
+ const { lastCompleted, locators } = await hydrate(opened);
167
253
  if (replicas.size > 0) {
168
254
  emitRooms();
169
255
  emitVisible();
170
256
  }
257
+ for (const locator of locators)emitQueue(locator);
258
+ return lastCompleted;
259
+ }
260
+ async function applyRemote(message) {
261
+ if (closed) return;
262
+ if (message.type === 'hello') {
263
+ if (leader) announce();
264
+ return;
265
+ }
266
+ applyingRemote = true;
267
+ try {
268
+ const local = await openLocal();
269
+ const lastCompleted = await readState(local);
270
+ if (message.status !== undefined) adopt(message.status);
271
+ else {
272
+ adopt({
273
+ ...status,
274
+ lastCompleted,
275
+ attentionRequired: attention(),
276
+ state: pending() ? 'pending' : status.state
277
+ });
278
+ }
279
+ } finally{
280
+ applyingRemote = false;
281
+ }
282
+ if (message.type === 'enqueue' && leader) void enqueue();
283
+ }
284
+ async function open() {
285
+ const opened = await openReplicaStore();
286
+ store = opened;
287
+ await acrossTabs('mdruntime-generation', async ()=>{
288
+ const storedGeneration = await opened.generation();
289
+ const loss = detectStorageLoss(storedGeneration);
290
+ const generation = nextGeneration(storedGeneration);
291
+ if (generation !== storedGeneration) await opened.rememberGeneration(generation);
292
+ if (loss !== null) {
293
+ storageLoss = loss;
294
+ emitStorageLoss();
295
+ }
296
+ });
297
+ const lastCompleted = await readState(opened);
298
+ adopt({
299
+ ...status,
300
+ lastCompleted,
301
+ attentionRequired: attention()
302
+ });
303
+ if (!leader) announce('hello');
171
304
  return opened;
172
305
  }
173
306
  function openLocal() {
@@ -182,60 +315,234 @@ export function createMdRuntime(options) {
182
315
  mutations = next.then(()=>undefined, ()=>undefined);
183
316
  return next;
184
317
  }
318
+ function acrossTabs(name, work) {
319
+ if (typeof window === 'undefined') return work();
320
+ return window.navigator.locks.request(name, work);
321
+ }
185
322
  function publish(local, room, confirmed) {
186
323
  return exclusive(async ()=>{
187
- await local.publish(confirmed.record);
188
- unreadable.delete(room.locator);
189
- replicas.set(room.locator, replicaOf(confirmed, replicas.get(room.locator)?.queue ?? []));
324
+ await acrossTabs('mdruntime-replica', async ()=>{
325
+ await hydrate(local);
326
+ await local.publish(confirmed.record);
327
+ unreadable.delete(room.locator);
328
+ replicas.set(room.locator, replicaOf(confirmed, replicas.get(room.locator)?.queue ?? []));
329
+ });
190
330
  list(room, true);
191
331
  emitRooms();
192
332
  emitVisible();
333
+ announce();
193
334
  });
194
335
  }
195
- async function write(locator, changes) {
196
- const replica = replicas.get(locator);
197
- const room = rooms.get(locator);
198
- if (replica === undefined || room === undefined) {
199
- throw new Error(`No Local Replica for ${locator}`);
200
- }
201
- const built = buildBatch({
202
- baseRoomVersion: replica.confirmed.record.roomVersion,
203
- writeGrants: room.writeGrants,
204
- providerOwnedPaths: room.providerOwnedPaths,
205
- providerLimits: limits,
206
- visible: replica.visible,
207
- changes
336
+ async function write(locator, changes, replacing) {
337
+ const local = await openLocal();
338
+ const result = await acrossTabs('mdruntime-replica', async ()=>{
339
+ await hydrate(local);
340
+ const replica = replicas.get(locator);
341
+ const room = rooms.get(locator);
342
+ if (replica === undefined || room === undefined) {
343
+ throw new Error(`No Local Replica for ${locator}`);
344
+ }
345
+ const built = buildBatch({
346
+ baseRoomVersion: replica.confirmed.record.roomVersion,
347
+ writeGrants: room.writeGrants,
348
+ providerOwnedPaths: room.providerOwnedPaths,
349
+ providerLimits: limits,
350
+ visible: replica.visible,
351
+ changes
352
+ });
353
+ if (built.kind === 'refused') return {
354
+ outcome: 'refused',
355
+ ...built.refusal
356
+ };
357
+ const head = replica.queue[0];
358
+ if (replacing !== undefined) {
359
+ if (head === undefined || head.id !== replacing || !refused(outcomes.get(head.id))) {
360
+ return {
361
+ outcome: 'refused',
362
+ rule: 'no-refused-head'
363
+ };
364
+ }
365
+ }
366
+ const batches = replacing === undefined ? [
367
+ ...replica.queue,
368
+ built.batch
369
+ ] : [
370
+ built.batch,
371
+ ...replica.queue.slice(1)
372
+ ];
373
+ const consumed = [
374
+ ...new Set(changes.map((change)=>change.path))
375
+ ];
376
+ try {
377
+ await local.append({
378
+ locator,
379
+ batches,
380
+ outcomes: storedOutcomes(batches)
381
+ }, consumed);
382
+ } catch {
383
+ return {
384
+ outcome: 'refused',
385
+ rule: 'durable-write'
386
+ };
387
+ }
388
+ if (replacing !== undefined) outcomes.delete(replacing);
389
+ for (const path of consumed)drafts.delete(at(locator, path));
390
+ replicas.set(locator, replicaOf(replica.confirmed, batches));
391
+ emitQueue(locator);
392
+ emitVisible();
393
+ moveTo({
394
+ ...saving(status),
395
+ attentionRequired: attention()
396
+ });
397
+ return {
398
+ outcome: 'saved',
399
+ id: built.batch.id
400
+ };
208
401
  });
209
- if (built.kind === 'refused') return {
210
- outcome: 'refused',
211
- ...built.refusal
212
- };
402
+ if (result.outcome === 'saved') void enqueue();
403
+ return result;
404
+ }
405
+ async function discardHead(locator, identifier) {
213
406
  const local = await openLocal();
214
- const batches = [
215
- ...replica.queue,
216
- built.batch
217
- ];
218
- const consumed = [
219
- ...new Set(changes.map((change)=>change.path))
220
- ];
221
- await local.append({
407
+ const changed = await acrossTabs('mdruntime-replica', async ()=>{
408
+ await hydrate(local);
409
+ const replica = replicas.get(locator);
410
+ const head = replica?.queue[0];
411
+ if (replica === undefined || head === undefined || head.id !== identifier || !refused(outcomes.get(head.id))) {
412
+ return false;
413
+ }
414
+ const batches = replica.queue.slice(1);
415
+ outcomes.delete(identifier);
416
+ await persistQueue(local, locator, batches);
417
+ replicas.set(locator, replicaOf(replica.confirmed, batches));
418
+ emitQueue(locator);
419
+ emitVisible();
420
+ moveTo({
421
+ ...status,
422
+ state: pending() ? 'pending' : status.state,
423
+ attentionRequired: attention()
424
+ });
425
+ return true;
426
+ });
427
+ if (changed) void enqueue();
428
+ }
429
+ async function readHistory(locator, path, query = {}) {
430
+ if (closed || refusal !== null) return {
431
+ kind: 'transport-uncertainty'
432
+ };
433
+ try {
434
+ await ensureAuthorization();
435
+ } catch {
436
+ return {
437
+ kind: 'transport-uncertainty'
438
+ };
439
+ }
440
+ if (closed || authorization === undefined || refusal !== null) {
441
+ return {
442
+ kind: 'transport-uncertainty'
443
+ };
444
+ }
445
+ const transfer = [
446
+ 'transfer',
447
+ 'history',
222
448
  locator,
223
- batches
224
- }, consumed);
225
- for (const path of consumed)drafts.delete(at(locator, path));
226
- replicas.set(locator, replicaOf(replica.confirmed, batches));
227
- emitQueue(locator);
228
- emitVisible();
229
- moveTo(saving(status));
230
- return {
231
- outcome: 'saved',
232
- id: built.batch.id
449
+ path
450
+ ];
451
+ if (query.revision !== undefined) transfer.push(query.revision);
452
+ if (query.pageToken !== undefined) transfer.push(query.pageToken);
453
+ journal.record(transfer.join(' '));
454
+ try {
455
+ const result = await options.adapter.history(authorization, {
456
+ locator,
457
+ path,
458
+ ...query.revision === undefined ? {} : {
459
+ revision: query.revision
460
+ },
461
+ ...query.pageToken === undefined ? {} : {
462
+ pageToken: query.pageToken
463
+ }
464
+ });
465
+ journal.record(`outcome history ${result.kind}`);
466
+ if (result.kind === 'revoked-authorization') refuse('revoked-authorization');
467
+ return result;
468
+ } catch {
469
+ journal.record('outcome history transport-uncertainty');
470
+ return {
471
+ kind: 'transport-uncertainty'
472
+ };
473
+ }
474
+ }
475
+ function declaredAction(name) {
476
+ for (const capability of capabilities){
477
+ for (const action of capability.actions ?? []){
478
+ if (action.name === name) return action;
479
+ }
480
+ }
481
+ return undefined;
482
+ }
483
+ async function invokeAction(name, input) {
484
+ await ready;
485
+ if (closed || refusal !== null) return {
486
+ kind: 'transport-uncertainty'
487
+ };
488
+ if (declaredAction(name) === undefined) return {
489
+ kind: 'undeclared'
233
490
  };
491
+ try {
492
+ await ensureAuthorization();
493
+ } catch {
494
+ return {
495
+ kind: 'transport-uncertainty'
496
+ };
497
+ }
498
+ if (closed || authorization === undefined || refusal !== null) {
499
+ return {
500
+ kind: 'transport-uncertainty'
501
+ };
502
+ }
503
+ journal.record(`transfer action ${name}`);
504
+ try {
505
+ const result = await options.adapter.action(authorization, {
506
+ name,
507
+ input
508
+ });
509
+ if (result.kind === 'revoked-authorization') refuse('revoked-authorization');
510
+ journal.record(`outcome action ${result.kind}`);
511
+ if (result.kind === 'rejected') journal.record(`refusal rejected ${result.text}`);
512
+ return result;
513
+ } catch {
514
+ journal.record('outcome action transport-uncertainty');
515
+ return {
516
+ kind: 'transport-uncertainty'
517
+ };
518
+ }
519
+ }
520
+ async function pullRefresh(authorized, confirmed) {
521
+ try {
522
+ const result = await refreshRoom(options.adapter, authorized, confirmed);
523
+ const detail = result.kind === 'advanced' ? result.confirmed.record.roomVersion : result.kind;
524
+ journal.record(`transfer refresh ${confirmed.record.locator} ${detail}`);
525
+ return result;
526
+ } catch (error) {
527
+ journal.record(`transfer refresh ${confirmed.record.locator} transport-uncertainty`);
528
+ throw error;
529
+ }
530
+ }
531
+ async function pullBootstrap(authorized, room) {
532
+ try {
533
+ const result = await bootstrapRoom(options.adapter, authorized, room);
534
+ const detail = result.kind === 'staged' ? result.confirmed.record.roomVersion : result.kind;
535
+ journal.record(`transfer bootstrap ${room.locator} ${detail}`);
536
+ return result;
537
+ } catch (error) {
538
+ journal.record(`transfer bootstrap ${room.locator} transport-uncertainty`);
539
+ throw error;
540
+ }
234
541
  }
235
542
  async function advance(room, local, authorized) {
236
543
  const held = replicas.get(room.locator);
237
544
  if (held !== undefined) {
238
- const refreshed = await refreshRoom(options.adapter, authorized, held.confirmed);
545
+ const refreshed = await pullRefresh(authorized, held.confirmed);
239
546
  if (refreshed.kind === 'still-current') {
240
547
  if (unreadable.delete(room.locator)) {
241
548
  list(room, true);
@@ -250,62 +557,248 @@ export function createMdRuntime(options) {
250
557
  }
251
558
  if (refreshed.kind !== 'beyond-horizon') return refreshed;
252
559
  }
253
- const bootstrapped = await bootstrapRoom(options.adapter, authorized, room);
560
+ const bootstrapped = await pullBootstrap(authorized, room);
254
561
  if (bootstrapped.kind !== 'staged') return bootstrapped;
255
562
  if (closed) return unconfirmed;
256
563
  await publish(local, room, bootstrapped.confirmed);
257
564
  return null;
258
565
  }
259
- async function synchronize(discovered, local, progress) {
260
- for (const room of discovered){
261
- if (closed || authorization === undefined) {
566
+ function recordRefusal(result) {
567
+ if (result.kind === 'expired') return {
568
+ kind: 'expired'
569
+ };
570
+ if (result.kind === 'conflicted') {
571
+ return {
572
+ kind: 'conflicted',
573
+ roomVersion: result.roomVersion,
574
+ affected: result.affected,
575
+ text: result.text
576
+ };
577
+ }
578
+ return {
579
+ kind: 'rejected',
580
+ affected: result.affected,
581
+ text: result.text
582
+ };
583
+ }
584
+ async function commitAccept(room, local, submitted, accepted, base, overlay) {
585
+ return acrossTabs('mdruntime-replica', async ()=>{
586
+ await hydrate(local);
587
+ const current = replicas.get(room.locator);
588
+ if (current === undefined || !current.queue.some((batch)=>batch.id === submitted.id)) {
589
+ return true;
590
+ }
591
+ const remaining = current.queue.filter((batch)=>batch.id !== submitted.id);
592
+ const applied = overlay ? applyReturned(base, accepted.documents, accepted.revisions, accepted.removals, accepted.roomVersion) : base;
593
+ if (applied === null) return false;
594
+ await local.publish(applied.record);
595
+ await persistQueue(local, room.locator, remaining);
596
+ outcomes.set(submitted.id, {
597
+ kind: 'accepted',
598
+ roomVersion: accepted.roomVersion,
599
+ author: accepted.author,
600
+ time: accepted.time
601
+ });
602
+ unreadable.delete(room.locator);
603
+ replicas.set(room.locator, replicaOf(applied, remaining));
604
+ list(room, true);
605
+ emitRooms();
606
+ emitQueue(room.locator);
607
+ emitVisible();
608
+ journal.record(`outcome ${room.locator} ${submitted.id} accepted`);
609
+ return true;
610
+ });
611
+ }
612
+ async function submitRoom(room, local, authorized, progress) {
613
+ while(!closed && authorization !== undefined){
614
+ const replica = replicas.get(room.locator);
615
+ const head = replica?.queue[0];
616
+ if (replica === undefined || head === undefined || refused(outcomes.get(head.id))) {
617
+ return null;
618
+ }
619
+ let result;
620
+ journal.record(`submission ${room.locator} ${head.id}`);
621
+ try {
622
+ result = await options.adapter.mutate(authorized, {
623
+ locator: room.locator,
624
+ changeBatch: head
625
+ });
626
+ } catch {
262
627
  progress.complete = false;
263
- return;
628
+ progress.reachable = false;
629
+ return null;
264
630
  }
265
- const withheld = await advance(room, local, authorization);
266
- if (withheld === null) continue;
267
- progress.complete = false;
268
- if (withheld.kind === 'transport-uncertainty') {
631
+ if (closed) return null;
632
+ if (result.kind === 'transport-uncertainty') {
633
+ progress.complete = false;
269
634
  progress.reachable = false;
270
- continue;
635
+ return null;
271
636
  }
272
- if (withheld.kind === 'room-not-readable') {
637
+ if (result.kind === 'room-not-readable') {
638
+ progress.complete = false;
273
639
  unreadable.add(room.locator);
274
640
  list(room, false);
275
641
  emitRooms();
276
- continue;
642
+ return null;
277
643
  }
278
- if (withheld.kind === 'revoked-authorization') {
644
+ if (result.kind === 'revoked-authorization') {
279
645
  refuse('revoked-authorization');
646
+ return 'stop';
647
+ }
648
+ if (result.kind === 'conflicted' || result.kind === 'rejected' || result.kind === 'expired') {
649
+ journal.record(`outcome ${room.locator} ${head.id} ${result.kind}`);
650
+ if (result.kind !== 'expired') journal.record(`refusal ${result.kind} ${result.text}`);
651
+ await exclusive(async ()=>{
652
+ await acrossTabs('mdruntime-replica', async ()=>{
653
+ await hydrate(local);
654
+ const current = replicas.get(room.locator);
655
+ if (current === undefined || !current.queue.some((batch)=>batch.id === head.id)) {
656
+ return;
657
+ }
658
+ outcomes.set(head.id, recordRefusal(result));
659
+ await persistQueue(local, room.locator, current.queue);
660
+ emitQueue(room.locator);
661
+ moveTo({
662
+ ...status,
663
+ attentionRequired: true
664
+ });
665
+ });
666
+ });
667
+ return null;
668
+ }
669
+ const from = replicas.get(room.locator)?.confirmed;
670
+ if (from === undefined) return null;
671
+ const pulled = await pullRefresh(authorized, from);
672
+ if (pulled.kind === 'revoked-authorization') {
673
+ refuse('revoked-authorization');
674
+ return 'stop';
675
+ }
676
+ if (pulled.kind === 'still-current') {
677
+ const committed = await exclusive(()=>commitAccept(room, local, head, result, from, true));
678
+ if (!committed) {
679
+ progress.complete = false;
680
+ return null;
681
+ }
682
+ continue;
683
+ }
684
+ if (pulled.kind === 'advanced') {
685
+ const overlay = pulled.confirmed.record.roomVersion === result.roomVersion;
686
+ const committed = await exclusive(()=>commitAccept(room, local, head, result, pulled.confirmed, overlay));
687
+ if (!committed) {
688
+ progress.complete = false;
689
+ return null;
690
+ }
691
+ continue;
692
+ }
693
+ if (pulled.kind === 'beyond-horizon') {
694
+ const bootstrapped = await pullBootstrap(authorized, room);
695
+ if (bootstrapped.kind === 'revoked-authorization') {
696
+ refuse('revoked-authorization');
697
+ return 'stop';
698
+ }
699
+ if (bootstrapped.kind === 'staged') {
700
+ const overlay = bootstrapped.confirmed.record.roomVersion === result.roomVersion;
701
+ const committed = await exclusive(()=>commitAccept(room, local, head, result, bootstrapped.confirmed, overlay));
702
+ if (!committed) {
703
+ progress.complete = false;
704
+ return null;
705
+ }
706
+ continue;
707
+ }
708
+ progress.complete = false;
709
+ if (bootstrapped.kind === 'transport-uncertainty') {
710
+ progress.reachable = false;
711
+ } else if (bootstrapped.kind === 'room-not-readable') {
712
+ unreadable.add(room.locator);
713
+ list(room, false);
714
+ emitRooms();
715
+ }
716
+ return null;
717
+ }
718
+ progress.complete = false;
719
+ if (pulled.kind === 'transport-uncertainty') {
720
+ progress.reachable = false;
721
+ } else if (pulled.kind === 'room-not-readable') {
722
+ unreadable.add(room.locator);
723
+ list(room, false);
724
+ emitRooms();
725
+ }
726
+ return null;
727
+ }
728
+ return null;
729
+ }
730
+ async function synchronize(discovered, local, progress) {
731
+ for (const room of discovered){
732
+ if (closed || authorization === undefined) {
733
+ progress.complete = false;
280
734
  return;
281
735
  }
736
+ const withheld = await advance(room, local, authorization);
737
+ if (withheld !== null) {
738
+ progress.complete = false;
739
+ if (withheld.kind === 'transport-uncertainty') {
740
+ progress.reachable = false;
741
+ continue;
742
+ }
743
+ if (withheld.kind === 'room-not-readable') {
744
+ unreadable.add(room.locator);
745
+ list(room, false);
746
+ emitRooms();
747
+ continue;
748
+ }
749
+ if (withheld.kind === 'revoked-authorization') {
750
+ refuse('revoked-authorization');
751
+ return;
752
+ }
753
+ continue;
754
+ }
755
+ if (await submitRoom(room, local, authorization, progress) === 'stop') return;
282
756
  }
283
757
  }
284
758
  async function cycle(local, progress) {
285
- if (closed || authorization === undefined) return;
759
+ if (closed || authorization === undefined) {
760
+ notifyReady();
761
+ return;
762
+ }
286
763
  moveTo(transferring(status));
287
- const result = await options.adapter.discover(authorization);
288
- if (closed) return;
764
+ let result;
765
+ try {
766
+ result = await options.adapter.discover(authorization);
767
+ } catch (error) {
768
+ journal.record('connection discover transport-uncertainty');
769
+ notifyReady();
770
+ throw error;
771
+ }
772
+ if (closed) {
773
+ notifyReady();
774
+ return;
775
+ }
776
+ journal.record(`connection discover ${result.kind}`);
289
777
  if (result.kind === 'transport-uncertainty') {
290
778
  progress.reachable = false;
779
+ notifyReady();
291
780
  return;
292
781
  }
293
782
  if (result.kind === 'revoked-authorization') {
294
783
  refuse('revoked-authorization');
784
+ notifyReady();
295
785
  return;
296
786
  }
297
787
  if (!result.supportedVersions.includes(ROOM_CONTRACT_VERSION)) {
298
788
  refuse('no-matching-version');
789
+ notifyReady();
299
790
  return;
300
791
  }
301
792
  if (result.mdModel.identity !== MD_MODEL.identity || result.mdModel.version !== MD_MODEL.version) {
302
793
  refuse('md-model-version-mismatch');
794
+ notifyReady();
303
795
  return;
304
796
  }
305
797
  const declared = new Set(result.capabilities.map((capability)=>capability.name));
306
798
  for (const required of options.requiredGuarantees){
307
799
  if (!knownCapabilities.has(required) || !declared.has(required)) {
308
800
  refuse('missing-required-guarantee');
801
+ notifyReady();
309
802
  return;
310
803
  }
311
804
  }
@@ -313,6 +806,8 @@ export function createMdRuntime(options) {
313
806
  refusal = null;
314
807
  emitRefusal();
315
808
  }
809
+ capabilities = result.capabilities;
810
+ notifyReady();
316
811
  if (JSON.stringify(result.providerLimits) !== JSON.stringify(limits)) {
317
812
  limits = result.providerLimits;
318
813
  await local.declare(limits);
@@ -325,10 +820,19 @@ export function createMdRuntime(options) {
325
820
  }
326
821
  async function ensureAuthorization() {
327
822
  if (authorization !== undefined) return;
328
- authorization = await options.adapter.authorize();
823
+ try {
824
+ authorization = await options.adapter.authorize();
825
+ journal.record('connection authorize');
826
+ } catch (error) {
827
+ journal.record('connection authorize transport-uncertainty');
828
+ throw error;
829
+ }
329
830
  }
330
831
  async function connect() {
331
- if (closed || discoveryPaused) return;
832
+ if (closed || discoveryPaused) {
833
+ notifyReady();
834
+ return;
835
+ }
332
836
  startTimer();
333
837
  const progress = {
334
838
  complete: false,
@@ -348,9 +852,11 @@ export function createMdRuntime(options) {
348
852
  }
349
853
  } catch {
350
854
  progress.complete = false;
855
+ notifyReady();
351
856
  return;
352
857
  } finally{
353
- moveTo(settled(status, progress, completedAt, pending()));
858
+ notifyReady();
859
+ moveTo(settled(status, progress, completedAt, pending(), attention()));
354
860
  }
355
861
  }
356
862
  async function run() {
@@ -365,6 +871,10 @@ export function createMdRuntime(options) {
365
871
  }
366
872
  }
367
873
  function enqueue() {
874
+ if (!leader) {
875
+ announce('enqueue');
876
+ return chain;
877
+ }
368
878
  if (inflight) {
369
879
  queued = true;
370
880
  return chain;
@@ -373,21 +883,54 @@ export function createMdRuntime(options) {
373
883
  chain = chain.then(run, run);
374
884
  return chain;
375
885
  }
886
+ function startProcessing() {
887
+ if (leader) {
888
+ void enqueue();
889
+ return;
890
+ }
891
+ void window.navigator.locks.request('mdruntime-sync', {
892
+ signal: options.signal
893
+ }, async ()=>{
894
+ if (closed) return;
895
+ leader = true;
896
+ announce();
897
+ void enqueue();
898
+ await new Promise((resolve)=>{
899
+ releaseLeadership = ()=>{
900
+ leader = false;
901
+ resolve();
902
+ };
903
+ });
904
+ }).catch(()=>{});
905
+ }
376
906
  function close() {
377
907
  closed = true;
378
908
  stopTimer();
909
+ releaseLeadership();
910
+ channel?.close();
379
911
  store?.close();
380
912
  roomListeners.clear();
381
913
  refusalListeners.clear();
914
+ storageLossListeners.clear();
382
915
  statusListeners.clear();
383
916
  querySubscriptions.clear();
384
917
  queueSubscriptions.clear();
385
918
  }
919
+ channel?.addEventListener('message', (event)=>{
920
+ const data = event.data;
921
+ if (data === null || typeof data !== 'object') return;
922
+ void exclusive(()=>applyRemote(data));
923
+ });
386
924
  options.signal?.addEventListener('abort', close, {
387
925
  once: true
388
926
  });
389
927
  if (options.signal?.aborted) close();
390
- void enqueue();
928
+ void openLocal().then(()=>{
929
+ if (!leader) notifyReady();
930
+ }, ()=>{
931
+ notifyReady();
932
+ });
933
+ startProcessing();
391
934
  return {
392
935
  rooms () {
393
936
  return listed();
@@ -420,8 +963,35 @@ export function createMdRuntime(options) {
420
963
  drafts.delete(at(locator, path));
421
964
  }
422
965
  },
423
- save (locator, changes) {
424
- return exclusive(()=>write(locator, changes));
966
+ save (locator, changes, replacing) {
967
+ return exclusive(()=>write(locator, changes, replacing));
968
+ },
969
+ outcome (identifier) {
970
+ const recorded = outcomes.get(identifier);
971
+ if (recorded !== undefined) return recorded;
972
+ for (const replica of replicas.values()){
973
+ if (replica.queue.some((batch)=>batch.id === identifier)) return {
974
+ kind: 'pending'
975
+ };
976
+ }
977
+ return null;
978
+ },
979
+ queue: {
980
+ discard (locator, identifier) {
981
+ return exclusive(()=>discardHead(locator, identifier));
982
+ }
983
+ },
984
+ history (locator, path, query) {
985
+ return readHistory(locator, path, query);
986
+ },
987
+ call (name, input) {
988
+ return invokeAction(name, input);
989
+ },
990
+ log () {
991
+ return journal.export();
992
+ },
993
+ storageLoss () {
994
+ return storageLoss;
425
995
  },
426
996
  observe (topic, second, third) {
427
997
  if (topic === 'rooms') {
@@ -440,6 +1010,14 @@ export function createMdRuntime(options) {
440
1010
  refusalListeners.delete(notify);
441
1011
  };
442
1012
  }
1013
+ if (topic === 'storage-loss') {
1014
+ const notify = second;
1015
+ storageLossListeners.add(notify);
1016
+ notify(storageLoss);
1017
+ return ()=>{
1018
+ storageLossListeners.delete(notify);
1019
+ };
1020
+ }
443
1021
  if (topic === 'sync-status') {
444
1022
  const notify = second;
445
1023
  statusListeners.add(notify);
@@ -454,7 +1032,7 @@ export function createMdRuntime(options) {
454
1032
  notify: third
455
1033
  };
456
1034
  queueSubscriptions.add(queue);
457
- queue.notify(queueOf(queue.locator));
1035
+ queue.notify(entriesOf(queue.locator));
458
1036
  return ()=>{
459
1037
  queueSubscriptions.delete(queue);
460
1038
  };
@@ -472,6 +1050,7 @@ export function createMdRuntime(options) {
472
1050
  },
473
1051
  async reauthorize () {
474
1052
  if (refusal === null || !refusal.reauthorizationOffered) return;
1053
+ journal.record('connection reauthorize');
475
1054
  authorization = undefined;
476
1055
  discoveryPaused = false;
477
1056
  await enqueue();