agl 40.6.0 → 41.0.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 (82) hide show
  1. package/changelog.md +33 -0
  2. package/dist_serve/bundle.js +3514 -3357
  3. package/dist_serve/bundle.js.map +2 -2
  4. package/dist_serve/bundle.js.third-party-notices.json +58 -32
  5. package/dist_serve/bundle.js.third-party-notices.txt +108 -33
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/aglapi/aglapisurface.document.js +64 -37
  8. package/dist_ts/classes.controller.d.ts +8 -0
  9. package/dist_ts/classes.controller.js +109 -21
  10. package/dist_ts/classes.controllereventdelivery.d.ts +72 -0
  11. package/dist_ts/classes.controllereventdelivery.js +233 -0
  12. package/dist_ts/classes.flexmodels.public.js +4 -1
  13. package/dist_ts/classes.flexprojectionreader.d.ts +6 -0
  14. package/dist_ts/classes.flexprojectionreader.js +29 -5
  15. package/dist_ts/classes.flexservice.d.ts +2 -1
  16. package/dist_ts/classes.flexservice.js +34 -6
  17. package/dist_ts/classes.flexstore.d.ts +41 -4
  18. package/dist_ts/classes.flexstore.js +266 -203
  19. package/dist_ts/classes.ticketnotifier.js +4 -2
  20. package/dist_ts/classes.ticketstore.d.ts +50 -2
  21. package/dist_ts/classes.ticketstore.js +195 -78
  22. package/dist_ts/driver/classes.drivereventrelay.d.ts +13 -1
  23. package/dist_ts/driver/classes.drivereventrelay.js +99 -6
  24. package/dist_ts/driver/functions.driverinstructions.d.ts +1 -1
  25. package/dist_ts/driver/functions.driverinstructions.js +1 -1
  26. package/dist_ts/driver/functions.drivertools.d.ts +11 -2
  27. package/dist_ts/driver/functions.drivertools.js +19 -9
  28. package/dist_ts/fleet/classes.boardpump.d.ts +8 -2
  29. package/dist_ts/fleet/classes.boardpump.js +38 -20
  30. package/dist_ts/fleet/classes.ticketeffects.d.ts +17 -2
  31. package/dist_ts/fleet/classes.ticketeffects.js +16 -10
  32. package/dist_ts/fleet/classes.workers.d.ts +4 -0
  33. package/dist_ts/fleet/classes.workers.js +37 -17
  34. package/dist_ts/fleet/functions.workervalidation.js +2 -2
  35. package/dist_ts/functions.flexprivatesnapshot.d.ts +102 -0
  36. package/dist_ts/functions.flexprivatesnapshot.js +461 -0
  37. package/dist_ts/functions.mcptickettools.d.ts +3 -0
  38. package/dist_ts/functions.mcptickettools.js +2 -2
  39. package/dist_ts/functions.ticketcli.d.ts +1 -1
  40. package/dist_ts/functions.ticketcli.js +8 -4
  41. package/dist_ts/functions.ticketvalidation.d.ts +3 -0
  42. package/dist_ts/functions.ticketvalidation.js +7 -4
  43. package/dist_ts/interfaces.flexipc.d.ts +21 -2
  44. package/dist_ts/interfaces.flexipc.js +33 -16
  45. package/dist_ts_interfaces/fleetworkers.d.ts +6 -0
  46. package/dist_ts_interfaces/interfaces.d.ts +1 -1
  47. package/dist_ts_interfaces/interfaces.js +1 -1
  48. package/dist_ts_interfaces/requests.tickets.d.ts +8 -12
  49. package/dist_ts_interfaces/tickets.d.ts +37 -9
  50. package/dist_ts_interfaces/tickets.js +7 -1
  51. package/package.json +7 -7
  52. package/readme.md +54 -10
  53. package/ts/00_commitinfo_data.ts +1 -1
  54. package/ts/aglapi/aglapisurface.document.ts +63 -36
  55. package/ts/classes.controller.ts +131 -31
  56. package/ts/classes.controllereventdelivery.ts +289 -0
  57. package/ts/classes.flexmodels.public.ts +10 -0
  58. package/ts/classes.flexprojectionreader.ts +30 -3
  59. package/ts/classes.flexservice.ts +33 -5
  60. package/ts/classes.flexstore.ts +381 -246
  61. package/ts/classes.ticketnotifier.ts +3 -1
  62. package/ts/classes.ticketstore.ts +201 -75
  63. package/ts/driver/classes.drivereventrelay.ts +89 -4
  64. package/ts/driver/functions.driverinstructions.ts +1 -1
  65. package/ts/driver/functions.drivertools.ts +22 -8
  66. package/ts/fleet/classes.boardpump.ts +51 -19
  67. package/ts/fleet/classes.ticketeffects.ts +25 -11
  68. package/ts/fleet/classes.workers.ts +28 -16
  69. package/ts/fleet/functions.workervalidation.ts +1 -1
  70. package/ts/functions.flexprivatesnapshot.ts +601 -0
  71. package/ts/functions.mcptickettools.ts +1 -1
  72. package/ts/functions.ticketcli.ts +7 -3
  73. package/ts/functions.ticketvalidation.ts +7 -3
  74. package/ts/interfaces.flexipc.ts +50 -21
  75. package/ts_interfaces/fleetworkers.ts +6 -0
  76. package/ts_interfaces/interfaces.ts +1 -1
  77. package/ts_interfaces/requests.tickets.ts +9 -6
  78. package/ts_interfaces/tickets.ts +32 -1
  79. package/ts_web/00_commitinfo_data.ts +1 -1
  80. package/ts_web/elements.harnesscontrollerapp.ts +19 -8
  81. package/ts_web/elements.ticketboard.ts +16 -4
  82. package/ts_web/settings.general.ts +10 -4
@@ -87,6 +87,7 @@ import {
87
87
  } from './functions.canonicaldirectory.js';
88
88
  import { commitinfo } from './00_commitinfo_data.js';
89
89
  import { controllerFailureJournal } from './classes.failurejournal.js';
90
+ import { ControllerEventDelivery } from './classes.controllereventdelivery.js';
90
91
  import {
91
92
  controllerPackageName,
92
93
  controllerBuiltinCommands,
@@ -929,6 +930,9 @@ const maxSessionArchiveReconciliationReads = 3;
929
930
  const maxLatestOpenCodeToolExecutions = 512;
930
931
  const maxLatestOpenCodeMessageUpdates = 512;
931
932
  const maxPendingFlexControllerEvents = 512;
933
+ /** Events and serialized bytes one browser may have waiting before its queue collapses into a resync. */
934
+ const maxPendingControllerEventsPerViewer = 512;
935
+ const maxPendingControllerEventBytesPerViewer = 16 * 1024 * 1024;
932
936
  const maxLatestFlexToolExecutions = 512;
933
937
  const maxLatestFlexToolExecutionBytes = 8 * 1024 * 1024;
934
938
  const maxLatestFlexMessageUpdateBytes = 16 * 1024 * 1024;
@@ -1352,6 +1356,12 @@ interface ILatestTextUpdate {
1352
1356
  bytes?: number;
1353
1357
  }
1354
1358
 
1359
+ /** A live Flex reasoning snapshot with the accounting its deltas extend. */
1360
+ type TLatestFlexReasoningUpdate = ILatestReasoningUpdate & IFlexLiveTextAccounting;
1361
+
1362
+ /** A live Flex text snapshot with the accounting its deltas extend. */
1363
+ type TLatestFlexTextUpdate = ILatestTextUpdate & IFlexLiveTextAccounting;
1364
+
1355
1365
  type TControllerMessageDelta = IControllerReasoningDelta | IControllerTextDelta;
1356
1366
 
1357
1367
  interface IControllerTextDeltaFragment {
@@ -1389,6 +1399,67 @@ const controllerTextAfterAppendUtf8Bytes = (
1389
1399
  return Number.isSafeInteger(result) && result >= 0 ? result : undefined;
1390
1400
  };
1391
1401
 
1402
+ const isHighSurrogate = (codeArg: number): boolean => codeArg >= 0xD800 && codeArg <= 0xDBFF;
1403
+ const isLowSurrogate = (codeArg: number): boolean => codeArg >= 0xDC00 && codeArg <= 0xDFFF;
1404
+
1405
+ /** The UTF-8 bytes `JSON.stringify` writes for a string's content (without its quotes), counted without serializing. */
1406
+ const controllerJsonStringContentBytes = (valueArg: string): number => {
1407
+ let bytes = 0;
1408
+ for (let index = 0; index < valueArg.length; index += 1) {
1409
+ const code = valueArg.charCodeAt(index);
1410
+ if (
1411
+ code === 0x22 || code === 0x5C
1412
+ || code === 0x08 || code === 0x09 || code === 0x0A || code === 0x0C || code === 0x0D
1413
+ ) bytes += 2;
1414
+ else if (code < 0x20) bytes += 6;
1415
+ else if (code < 0x80) bytes += 1;
1416
+ else if (code < 0x800) bytes += 2;
1417
+ else if (isHighSurrogate(code)) {
1418
+ if (index + 1 < valueArg.length && isLowSurrogate(valueArg.charCodeAt(index + 1))) {
1419
+ bytes += 4;
1420
+ index += 1;
1421
+ } else bytes += 6;
1422
+ } else if (isLowSurrogate(code)) bytes += 6;
1423
+ else bytes += 3;
1424
+ }
1425
+ return bytes;
1426
+ };
1427
+
1428
+ /**
1429
+ * What a live Flex snapshot's text costs, kept beside the text so a delta is accounted by its own length: the
1430
+ * cumulative text is never encoded, serialized or even read again on the append path.
1431
+ */
1432
+ interface IFlexLiveTextAccounting {
1433
+ textUtf8Bytes: number;
1434
+ /** `JSON.stringify(text)` bytes without the quotes. */
1435
+ textJsonBytes: number;
1436
+ textEndsWithHighSurrogate: boolean;
1437
+ }
1438
+
1439
+ const flexLiveTextAccounting = (textArg: string): IFlexLiveTextAccounting => ({
1440
+ textUtf8Bytes: Buffer.byteLength(textArg, 'utf8'),
1441
+ textJsonBytes: controllerJsonStringContentBytes(textArg),
1442
+ textEndsWithHighSurrogate: textArg.length > 0 && isHighSurrogate(textArg.charCodeAt(textArg.length - 1)),
1443
+ });
1444
+
1445
+ const flexLiveTextAccountingAfterAppend = (
1446
+ accountingArg: IFlexLiveTextAccounting,
1447
+ deltaArg: string,
1448
+ ): IFlexLiveTextAccounting | undefined => {
1449
+ if (deltaArg.length === 0) return accountingArg;
1450
+ // A surrogate pair split across the append: two lone halves (3 + 3 UTF-8 bytes, `\uXXXX` + `\uXXXX` in JSON)
1451
+ // become one pair (4 bytes in both).
1452
+ const joinsPair = accountingArg.textEndsWithHighSurrogate && isLowSurrogate(deltaArg.charCodeAt(0));
1453
+ const textUtf8Bytes = accountingArg.textUtf8Bytes + Buffer.byteLength(deltaArg, 'utf8') - (joinsPair ? 2 : 0);
1454
+ const textJsonBytes = accountingArg.textJsonBytes + controllerJsonStringContentBytes(deltaArg) - (joinsPair ? 8 : 0);
1455
+ if (!Number.isSafeInteger(textUtf8Bytes) || !Number.isSafeInteger(textJsonBytes)) return undefined;
1456
+ return {
1457
+ textUtf8Bytes,
1458
+ textJsonBytes,
1459
+ textEndsWithHighSurrogate: isHighSurrogate(deltaArg.charCodeAt(deltaArg.length - 1)),
1460
+ };
1461
+ };
1462
+
1392
1463
  const fragmentControllerTextDelta = (valueArg: string): IControllerTextDeltaFragment[] => {
1393
1464
  const fragments: IControllerTextDeltaFragment[] = [];
1394
1465
  let delta = '';
@@ -3031,8 +3102,8 @@ export class OpenCodeController {
3031
3102
  private readonly latestOpenCodeReasoningUpdates = new Map<string, ILatestReasoningUpdate>();
3032
3103
  private readonly latestOpenCodeTextUpdates = new Map<string, ILatestTextUpdate>();
3033
3104
  private readonly latestFlexToolExecutions = new Map<string, ILatestToolExecution>();
3034
- private readonly latestFlexReasoningUpdates = new Map<string, ILatestReasoningUpdate>();
3035
- private readonly latestFlexTextUpdates = new Map<string, ILatestTextUpdate>();
3105
+ private readonly latestFlexReasoningUpdates = new Map<string, TLatestFlexReasoningUpdate>();
3106
+ private readonly latestFlexTextUpdates = new Map<string, TLatestFlexTextUpdate>();
3036
3107
  private readonly pendingFlexControllerEvents = new Map<string, IControllerEvent>();
3037
3108
  private readonly pendingFlexAutoAcceptSweeps = new Map<string, IFlexAutoAcceptSweep>();
3038
3109
  private openCodeControllerEventDrainTask?: Promise<void>;
@@ -3049,6 +3120,7 @@ export class OpenCodeController {
3049
3120
  private pendingFlexToolEventBytes = 0;
3050
3121
  private pendingFlexMessageEventBytes = 0;
3051
3122
  private flexControllerEventDrainTask?: Promise<void>;
3123
+ private controllerEventDelivery?: ControllerEventDelivery;
3052
3124
  private flexAutoAcceptDrainTask?: Promise<void>;
3053
3125
  /** Sweeps whose auto-accept failed, queued again when the retry timer fires. */
3054
3126
  private readonly failedFlexAutoAcceptSweeps = new Map<string, IFlexAutoAcceptSweep>();
@@ -13230,6 +13302,7 @@ export class OpenCodeController {
13230
13302
  ...(this.openCodeControllerEventDrainTask ? [this.openCodeControllerEventDrainTask] : []),
13231
13303
  ...(this.flexControllerEventDrainTask ? [this.flexControllerEventDrainTask] : []),
13232
13304
  ]);
13305
+ await this.controllerEventDelivery?.stop();
13233
13306
 
13234
13307
  if (this.flexProjectionReader) {
13235
13308
  await stopPart(async () => {
@@ -16757,7 +16830,7 @@ export class OpenCodeController {
16757
16830
  return { owner, ...(configuration.boardPump ? { configuration: configuration.boardPump } : {}) };
16758
16831
  },
16759
16832
  tickets: async () => (await this.ticketStore.execute('list', {}, undefined)).tickets,
16760
- ticket: (ticketKeyArg) => this.ticketStore.execute('get', { ticketId: ticketKeyArg }, undefined),
16833
+ history: (ticketKeyArg) => this.ticketStore.history(ticketKeyArg),
16761
16834
  workers: (ownerArg) => this.requireFleetWorkers().views(ownerArg),
16762
16835
  steer: (sessionIdArg, ticketKeyArg, textArg) => this.requireFleetWorkers().notify(sessionIdArg, ticketKeyArg, textArg),
16763
16836
  publish: (eventArg, ownerArg) => this.requireFleetWorkers().events.publish(eventArg, ownerArg),
@@ -17631,6 +17704,7 @@ export class OpenCodeController {
17631
17704
  driver: {
17632
17705
  instructions: service.instructions(await this.readDriverConfiguration()),
17633
17706
  notes: state.scratchpad.text,
17707
+ notesRevision: state.scratchpad.revision,
17634
17708
  },
17635
17709
  };
17636
17710
  }
@@ -23851,7 +23925,8 @@ export class OpenCodeController {
23851
23925
  let cursor: string | undefined;
23852
23926
  for (let pages = 0; ; pages++) {
23853
23927
  if (pages >= 1000) throw new Error('Flex delivery transcript exceeded its inspection limit.');
23854
- const page = await supervisorArg.request('message.page', {
23928
+ // Metadata pages: identity and state only, so a full scan never moves the transcript's text.
23929
+ const page = await supervisorArg.request('message.metadata.page', {
23855
23930
  ...payload, limit: 50, includeUndone: true, ...(cursor === undefined ? {} : { before: cursor }),
23856
23931
  }, signal);
23857
23932
  for (const message of page.messages) {
@@ -29188,7 +29263,7 @@ export class OpenCodeController {
29188
29263
 
29189
29264
  private setLatestFlexReasoningUpdate(
29190
29265
  keyArg: string,
29191
- entryArg: ILatestReasoningUpdate,
29266
+ entryArg: TLatestFlexReasoningUpdate,
29192
29267
  bytesArg: number,
29193
29268
  ): void {
29194
29269
  this.deleteLatestFlexReasoningUpdate(keyArg);
@@ -29198,7 +29273,7 @@ export class OpenCodeController {
29198
29273
 
29199
29274
  private setLatestFlexTextUpdate(
29200
29275
  keyArg: string,
29201
- entryArg: ILatestTextUpdate,
29276
+ entryArg: TLatestFlexTextUpdate,
29202
29277
  bytesArg: number,
29203
29278
  ): void {
29204
29279
  this.deleteLatestFlexTextUpdate(keyArg);
@@ -29721,7 +29796,7 @@ export class OpenCodeController {
29721
29796
  {
29722
29797
  projectId: eventArg.scopeId,
29723
29798
  update,
29724
- textUtf8Bytes: Buffer.byteLength(update.text, 'utf8'),
29799
+ ...flexLiveTextAccounting(update.text),
29725
29800
  },
29726
29801
  eventBytes,
29727
29802
  );
@@ -29788,7 +29863,7 @@ export class OpenCodeController {
29788
29863
  {
29789
29864
  projectId: eventArg.scopeId,
29790
29865
  update,
29791
- textUtf8Bytes: Buffer.byteLength(update.text, 'utf8'),
29866
+ ...flexLiveTextAccounting(update.text),
29792
29867
  },
29793
29868
  eventBytes,
29794
29869
  );
@@ -29836,13 +29911,11 @@ export class OpenCodeController {
29836
29911
  this.advanceFlexMessageStreamEpoch(true);
29837
29912
  return;
29838
29913
  }
29839
- const textUtf8Bytes = controllerTextAfterAppendUtf8Bytes(
29840
- previous.text,
29841
- entry.textUtf8Bytes,
29842
- eventArg.delta,
29843
- );
29914
+ const accounting = flexLiveTextAccountingAfterAppend(entry, eventArg.delta);
29915
+ const textUtf8Bytes = accounting?.textUtf8Bytes;
29844
29916
  if (
29845
- textUtf8Bytes === undefined
29917
+ accounting === undefined
29918
+ || textUtf8Bytes === undefined
29846
29919
  || textUtf8Bytes <= entry.textUtf8Bytes
29847
29920
  || textUtf8Bytes !== eventArg.textUtf8Bytes
29848
29921
  ) {
@@ -29851,9 +29924,11 @@ export class OpenCodeController {
29851
29924
  }
29852
29925
 
29853
29926
  const revision = this.flexMessageStreamRevision + 1;
29927
+ // Measured with empty text first: the snapshot's size is that frame plus the text's tracked JSON bytes, so
29928
+ // the growing text is serialized only when a snapshot is served.
29854
29929
  const update = {
29855
29930
  ...previous,
29856
- text: `${previous.text}${eventArg.delta}`,
29931
+ text: '',
29857
29932
  sourceUpdatedAt,
29858
29933
  revision,
29859
29934
  streamEpoch: this.flexMessageStreamEpoch,
@@ -29915,7 +29990,7 @@ export class OpenCodeController {
29915
29990
  timestamp: sourceUpdatedAt,
29916
29991
  };
29917
29992
  const eventBytes = Buffer.byteLength(JSON.stringify(controllerEvent), 'utf8');
29918
- const snapshotBytes = Buffer.byteLength(JSON.stringify(snapshotEvent), 'utf8');
29993
+ const snapshotBytes = Buffer.byteLength(JSON.stringify(snapshotEvent), 'utf8') + accounting.textJsonBytes;
29919
29994
  if (
29920
29995
  eventBytes > controllerMaxLiveMessageEventBytes
29921
29996
  || snapshotBytes > controllerMaxLiveMessageEventBytes
@@ -29926,6 +30001,7 @@ export class OpenCodeController {
29926
30001
  return;
29927
30002
  }
29928
30003
 
30004
+ update.text = `${previous.text}${eventArg.delta}`;
29929
30005
  this.flexMessageStreamRevision = revision;
29930
30006
  if (eventArg.partType === 'reasoning') {
29931
30007
  this.setLatestFlexReasoningUpdate(
@@ -29933,7 +30009,7 @@ export class OpenCodeController {
29933
30009
  {
29934
30010
  projectId: eventArg.scopeId,
29935
30011
  update: update as IControllerReasoningUpdate,
29936
- textUtf8Bytes,
30012
+ ...accounting,
29937
30013
  },
29938
30014
  snapshotBytes,
29939
30015
  );
@@ -29943,7 +30019,7 @@ export class OpenCodeController {
29943
30019
  {
29944
30020
  projectId: eventArg.scopeId,
29945
30021
  update: update as IControllerTextUpdate,
29946
- textUtf8Bytes,
30022
+ ...accounting,
29947
30023
  },
29948
30024
  snapshotBytes,
29949
30025
  );
@@ -30634,7 +30710,7 @@ export class OpenCodeController {
30634
30710
  {
30635
30711
  projectId: eventArg.projectId,
30636
30712
  update,
30637
- textUtf8Bytes: Buffer.byteLength(update.text, 'utf8'),
30713
+ ...flexLiveTextAccounting(update.text),
30638
30714
  },
30639
30715
  Buffer.byteLength(JSON.stringify(event), 'utf8'),
30640
30716
  );
@@ -30662,7 +30738,7 @@ export class OpenCodeController {
30662
30738
  {
30663
30739
  projectId: eventArg.projectId,
30664
30740
  update,
30665
- textUtf8Bytes: Buffer.byteLength(update.text, 'utf8'),
30741
+ ...flexLiveTextAccounting(update.text),
30666
30742
  },
30667
30743
  Buffer.byteLength(JSON.stringify(event), 'utf8'),
30668
30744
  );
@@ -35074,20 +35150,44 @@ export class OpenCodeController {
35074
35150
  ));
35075
35151
  }
35076
35152
 
35153
+ /** Every browser's own ordered event queue (`ControllerEventDelivery`), created with the first event. */
35154
+ private controllerEventDeliveryQueues(): ControllerEventDelivery {
35155
+ this.controllerEventDelivery ??= new ControllerEventDelivery({
35156
+ viewers: async () => {
35157
+ const typedSocket = this.typedServer?.typedsocket;
35158
+ if (!typedSocket) return [];
35159
+ const targets = await this.controllerEventViewers();
35160
+ return targets.map((targetArg) => ({
35161
+ peerId: targetArg.peer.id,
35162
+ send: async (eventArg: IControllerEvent) => {
35163
+ await typedSocket.createTypedRequest<IReq_ControllerEvent>(
35164
+ 'controller.event',
35165
+ targetArg,
35166
+ { timeoutMs: 2_000 },
35167
+ ).fire(eventArg, { timeoutMs: 2_000, maxRetries: 0 });
35168
+ },
35169
+ }));
35170
+ },
35171
+ eventKey: (eventArg) => this.controllerEventKey(eventArg),
35172
+ mergeDelta: mergeControllerMessageDeltaEvents,
35173
+ limits: {
35174
+ events: maxPendingControllerEventsPerViewer,
35175
+ bytes: maxPendingControllerEventBytesPerViewer,
35176
+ mergedDeltaBytes: controllerMaxLiveMessageEventBytes,
35177
+ },
35178
+ });
35179
+ return this.controllerEventDelivery;
35180
+ }
35181
+
35182
+ /**
35183
+ * The driver and the session feed see every event once, in order, before any browser; each browser then gets it
35184
+ * through its own queue, so one that acknowledges slowly delays only itself. Resolves once every browser's queue
35185
+ * holds the event, never on an acknowledgement.
35186
+ */
35077
35187
  private async pushControllerEvent(eventArg: IControllerEvent): Promise<void> {
35078
35188
  this.driverService?.observe(eventArg);
35079
35189
  this.sessionFeed?.observe(eventArg);
35080
- const typedSocket = this.typedServer?.typedsocket;
35081
- if (!typedSocket) return;
35082
- const targets = await this.controllerEventViewers();
35083
- await Promise.allSettled(targets.map(async (targetArg) => {
35084
- const request = typedSocket.createTypedRequest<IReq_ControllerEvent>(
35085
- 'controller.event',
35086
- targetArg,
35087
- { timeoutMs: 2_000 },
35088
- );
35089
- await request.fire(eventArg, { timeoutMs: 2_000, maxRetries: 0 });
35090
- }));
35190
+ await this.controllerEventDeliveryQueues().push(eventArg);
35091
35191
  }
35092
35192
 
35093
35193
  private runUpgradePreparationAdmission<T>(operationArg: () => Promise<T>): Promise<T> {
@@ -0,0 +1,289 @@
1
+ import type { IControllerEvent } from '../ts_interfaces/interfaces.js';
2
+
3
+ /** One authenticated browser connection that receives controller events. */
4
+ export interface IControllerEventViewer {
5
+ peerId: string;
6
+ /** Sends one event; resolves on the browser's acknowledgement, rejects on its timeout or a closed connection. */
7
+ send(eventArg: IControllerEvent): Promise<void>;
8
+ }
9
+
10
+ export interface IControllerEventDeliveryLimits {
11
+ /** Events one viewer may have waiting before its queue collapses into a resync. */
12
+ events: number;
13
+ /** Serialized bytes one viewer may have waiting before its queue collapses into a resync. */
14
+ bytes: number;
15
+ /** The largest live message delta a merge in a viewer's queue may produce. */
16
+ mergedDeltaBytes: number;
17
+ }
18
+
19
+ export interface IControllerEventDeliveryOptions {
20
+ /** The connections that receive events now; read once per batch of pushed events. */
21
+ viewers(): Promise<IControllerEventViewer[]>;
22
+ /** Events with one key carry the same state, so in a collapsed queue the newest replaces the older ones. */
23
+ eventKey(eventArg: IControllerEvent): string;
24
+ /** Merges a contiguous live message delta into the delta queued just before it, or refuses with `undefined`. */
25
+ mergeDelta(previousArg: IControllerEvent, eventArg: IControllerEvent): IControllerEvent | undefined;
26
+ limits: IControllerEventDeliveryLimits;
27
+ }
28
+
29
+ interface IQueuedControllerEvent {
30
+ event: IControllerEvent;
31
+ /** `JSON.stringify(event)` bytes; for a merged delta the sum of its parts, never less than its size. */
32
+ bytes: number;
33
+ }
34
+
35
+ interface IViewerQueue {
36
+ viewer: IControllerEventViewer;
37
+ /** Waiting events in send order. Normal keys are a sequence; a collapsed queue keys by state. */
38
+ events: Map<string, IQueuedControllerEvent>;
39
+ /** The key of the newest waiting event while the queue is not collapsed: where a delta may merge. */
40
+ tailKey?: string;
41
+ bytes: number;
42
+ /** Set when the queue overflowed and until it drains: events then replace older ones of their state. */
43
+ collapsed: boolean;
44
+ sending?: Promise<void>;
45
+ removed: boolean;
46
+ /** How many times this queue collapsed. */
47
+ collapses: number;
48
+ }
49
+
50
+ interface IPendingFanOut {
51
+ item: IQueuedControllerEvent;
52
+ delivered: () => void;
53
+ }
54
+
55
+ const isLiveEvent = (eventArg: IControllerEvent): boolean => Boolean(
56
+ eventArg.toolExecution
57
+ || eventArg.reasoningUpdate
58
+ || eventArg.reasoningDelta
59
+ || eventArg.textUpdate
60
+ || eventArg.textDelta,
61
+ );
62
+
63
+ /** The live stream content in one event, which only a delta's own successor or a re-read can replace. */
64
+ const isMessageDelta = (eventArg: IControllerEvent): boolean => Boolean(eventArg.reasoningDelta || eventArg.textDelta);
65
+
66
+ /**
67
+ * Delivers controller events to every viewer through its own bounded, ordered queue, so a viewer that acknowledges
68
+ * slowly or never delays only itself. Each viewer has at most one event in flight and receives events in push order.
69
+ *
70
+ * A viewer whose queue passes its limits is resynchronized alone, with the existing Flex queue's collapse rules:
71
+ * the queue keeps the newest event of each state key (statuses, barriers, feed and ticket revisions, settings),
72
+ * drops live tool, reasoning and text content, and ends with `sessions.changed` for each harness whose live content
73
+ * it dropped, which makes the browser re-read the conversations and the open session (live snapshots included).
74
+ * The viewer's own revision checks catch any delta that no longer fits. Should a collapsed queue still hold more
75
+ * than its event limit, each session's states fold into their harness's `sessions.changed` too, so what remains is
76
+ * one event per harness and per project or global state. The queue stays collapsed until it drains.
77
+ */
78
+ export class ControllerEventDelivery {
79
+ private readonly queues = new Map<string, IViewerQueue>();
80
+ private readonly fanOutQueue: IPendingFanOut[] = [];
81
+ private fanOutTask?: Promise<void>;
82
+ private sequence = 0;
83
+ private generation = 0;
84
+
85
+ constructor(private readonly options: IControllerEventDeliveryOptions) {}
86
+
87
+ /**
88
+ * Queues one event for every current viewer, in push order. Resolves once each viewer's queue holds it; it never
89
+ * waits for an acknowledgement.
90
+ */
91
+ public push(eventArg: IControllerEvent): Promise<void> {
92
+ const item: IQueuedControllerEvent = {
93
+ event: eventArg,
94
+ bytes: Buffer.byteLength(JSON.stringify(eventArg), 'utf8'),
95
+ };
96
+ return new Promise<void>((resolve) => {
97
+ this.fanOutQueue.push({ item, delivered: resolve });
98
+ this.startFanOut();
99
+ });
100
+ }
101
+
102
+ /** The events waiting for one viewer, in send order (the one in flight excluded). */
103
+ public waiting(peerIdArg: string): IControllerEvent[] {
104
+ return [...(this.queues.get(peerIdArg)?.events.values() ?? [])].map((itemArg) => itemArg.event);
105
+ }
106
+
107
+ /** How many times the queue of a connected viewer collapsed into a resync. */
108
+ public collapseCount(peerIdArg: string): number {
109
+ return this.queues.get(peerIdArg)?.collapses ?? 0;
110
+ }
111
+
112
+ /** Drops every waiting event and waits for the sends in flight; later pushes start fresh queues. */
113
+ public async stop(): Promise<void> {
114
+ this.generation += 1;
115
+ const sending: Promise<void>[] = [];
116
+ for (const queue of this.queues.values()) {
117
+ queue.removed = true;
118
+ queue.events.clear();
119
+ queue.bytes = 0;
120
+ if (queue.sending) sending.push(queue.sending);
121
+ }
122
+ this.queues.clear();
123
+ if (this.fanOutTask) sending.push(this.fanOutTask);
124
+ await Promise.allSettled(sending);
125
+ }
126
+
127
+ private startFanOut(): void {
128
+ if (this.fanOutTask) return;
129
+ const task = this.fanOut().finally(() => {
130
+ if (this.fanOutTask === task) this.fanOutTask = undefined;
131
+ if (this.fanOutQueue.length > 0) this.startFanOut();
132
+ });
133
+ this.fanOutTask = task;
134
+ }
135
+
136
+ private async fanOut(): Promise<void> {
137
+ while (this.fanOutQueue.length > 0) {
138
+ const batch = this.fanOutQueue.splice(0);
139
+ const generation = this.generation;
140
+ let viewers: IControllerEventViewer[] = [];
141
+ try {
142
+ viewers = await this.options.viewers();
143
+ } catch {
144
+ // No viewer can be named now; the events reach none, as a push without connections.
145
+ }
146
+ if (generation === this.generation) {
147
+ const current = new Set(viewers.map((viewerArg) => viewerArg.peerId));
148
+ for (const [peerId, queue] of this.queues) {
149
+ if (current.has(peerId)) continue;
150
+ queue.removed = true;
151
+ queue.events.clear();
152
+ queue.bytes = 0;
153
+ this.queues.delete(peerId);
154
+ }
155
+ for (const viewer of viewers) {
156
+ let queue = this.queues.get(viewer.peerId);
157
+ if (!queue) {
158
+ queue = { viewer, events: new Map(), bytes: 0, collapsed: false, removed: false, collapses: 0 };
159
+ this.queues.set(viewer.peerId, queue);
160
+ }
161
+ queue.viewer = viewer;
162
+ for (const pending of batch) this.enqueue(queue, pending.item);
163
+ this.startSending(queue);
164
+ }
165
+ }
166
+ for (const pending of batch) pending.delivered();
167
+ }
168
+ }
169
+
170
+ private enqueue(queueArg: IViewerQueue, itemArg: IQueuedControllerEvent): void {
171
+ if (queueArg.collapsed) {
172
+ this.absorb(queueArg, itemArg);
173
+ return;
174
+ }
175
+ if (isMessageDelta(itemArg.event) && queueArg.tailKey !== undefined) {
176
+ const tailKey = queueArg.tailKey;
177
+ const tail = queueArg.events.get(tailKey);
178
+ if (
179
+ tail !== undefined
180
+ && isMessageDelta(tail.event)
181
+ && tail.bytes + itemArg.bytes <= this.options.limits.mergedDeltaBytes
182
+ ) {
183
+ const merged = this.options.mergeDelta(tail.event, itemArg.event);
184
+ if (merged) {
185
+ queueArg.events.set(tailKey, { event: merged, bytes: tail.bytes + itemArg.bytes });
186
+ queueArg.bytes += itemArg.bytes;
187
+ if (queueArg.bytes > this.options.limits.bytes) this.collapse(queueArg);
188
+ return;
189
+ }
190
+ }
191
+ }
192
+ if (
193
+ queueArg.events.size + 1 > this.options.limits.events
194
+ || queueArg.bytes + itemArg.bytes > this.options.limits.bytes
195
+ ) {
196
+ this.collapse(queueArg);
197
+ this.absorb(queueArg, itemArg);
198
+ return;
199
+ }
200
+ this.sequence += 1;
201
+ queueArg.tailKey = `#${this.sequence}`;
202
+ queueArg.events.set(queueArg.tailKey, itemArg);
203
+ queueArg.bytes += itemArg.bytes;
204
+ }
205
+
206
+ private collapse(queueArg: IViewerQueue): void {
207
+ const dropped = [...queueArg.events.values()];
208
+ queueArg.events.clear();
209
+ queueArg.tailKey = undefined;
210
+ queueArg.bytes = 0;
211
+ queueArg.collapsed = true;
212
+ queueArg.collapses += 1;
213
+ for (const item of dropped) this.absorb(queueArg, item);
214
+ }
215
+
216
+ /** Adds an event to a collapsed queue: the newest of its state, or a resync of its harness for live content. */
217
+ private absorb(queueArg: IViewerQueue, itemArg: IQueuedControllerEvent): void {
218
+ if (isLiveEvent(itemArg.event)) {
219
+ this.resyncHarness(queueArg, itemArg.event);
220
+ } else {
221
+ this.place(queueArg, `state\0${this.options.eventKey(itemArg.event)}`, itemArg);
222
+ }
223
+ if (queueArg.events.size > this.options.limits.events) this.foldSessions(queueArg);
224
+ }
225
+
226
+ /**
227
+ * A collapsed queue that still holds more states than its limit keeps only what is not about one session: each
228
+ * session's states become their harness's `sessions.changed`, whose re-read covers the open session and the list.
229
+ */
230
+ private foldSessions(queueArg: IViewerQueue): void {
231
+ for (const [key, item] of [...queueArg.events]) {
232
+ if (!key.startsWith('state\0') || item.event.sessionId === undefined) continue;
233
+ queueArg.events.delete(key);
234
+ queueArg.bytes -= item.bytes;
235
+ this.resyncHarness(queueArg, item.event);
236
+ }
237
+ }
238
+
239
+ private resyncHarness(queueArg: IViewerQueue, eventArg: IControllerEvent): void {
240
+ const harnessId = eventArg.harnessId;
241
+ const event: IControllerEvent = {
242
+ type: 'sessions.changed',
243
+ ...(harnessId === undefined ? {} : { harnessId }),
244
+ timestamp: eventArg.timestamp,
245
+ };
246
+ this.place(
247
+ queueArg,
248
+ `resync\0${harnessId ?? ''}`,
249
+ { event, bytes: Buffer.byteLength(JSON.stringify(event), 'utf8') },
250
+ );
251
+ }
252
+
253
+ /** Puts an event last under its key, replacing an older one with that key. */
254
+ private place(queueArg: IViewerQueue, keyArg: string, itemArg: IQueuedControllerEvent): void {
255
+ const replaced = queueArg.events.get(keyArg);
256
+ if (replaced) {
257
+ queueArg.events.delete(keyArg);
258
+ queueArg.bytes -= replaced.bytes;
259
+ }
260
+ queueArg.events.set(keyArg, itemArg);
261
+ queueArg.bytes += itemArg.bytes;
262
+ }
263
+
264
+ private startSending(queueArg: IViewerQueue): void {
265
+ if (queueArg.sending || queueArg.events.size === 0) return;
266
+ const task = this.send(queueArg).finally(() => {
267
+ if (queueArg.sending === task) queueArg.sending = undefined;
268
+ if (!queueArg.removed && queueArg.events.size > 0) this.startSending(queueArg);
269
+ });
270
+ queueArg.sending = task;
271
+ }
272
+
273
+ private async send(queueArg: IViewerQueue): Promise<void> {
274
+ while (!queueArg.removed) {
275
+ const next = queueArg.events.entries().next().value as [string, IQueuedControllerEvent] | undefined;
276
+ if (!next) return;
277
+ queueArg.events.delete(next[0]);
278
+ queueArg.bytes -= next[1].bytes;
279
+ if (queueArg.tailKey === next[0]) queueArg.tailKey = undefined;
280
+ if (queueArg.events.size === 0) queueArg.collapsed = false;
281
+ try {
282
+ await queueArg.viewer.send(next[1].event);
283
+ } catch {
284
+ // As before: a viewer that does not acknowledge in time misses nothing it already received, and a closed
285
+ // connection leaves the viewer set at the next push.
286
+ }
287
+ }
288
+ }
289
+ }
@@ -727,6 +727,16 @@ export class FlexPublicHeadModel extends plugins.nosqldb.SmartDataDbDoc<
727
727
  name: 'flex_public_session_scope_purge',
728
728
  key: { controllerId: 1, scopeId: 1, id: 1 },
729
729
  })
730
+ /**
731
+ * One session's records in a scope: a reader finds the record a publication lists for a session
732
+ * among that session's versions, never among every session's records. Created by the startup
733
+ * init of the models (`initFlexPublicModels`, the projection reader's `init`), outside any
734
+ * transaction. An older AGL leaves the index it does not declare alone.
735
+ */
736
+ @plugins.nosqldb.compoundIndex({
737
+ name: 'flex_public_session_lookup',
738
+ key: { controllerId: 1, scopeId: 1, 'session.sessionId': 1 },
739
+ })
730
740
  @plugins.nosqldb.managed({ collectionName: flexPublicSessionsCollectionName })
731
741
  export class FlexPublicSessionRecordModel extends plugins.nosqldb.SmartDataDbDoc<
732
742
  FlexPublicSessionRecordModel,
@@ -414,9 +414,7 @@ export class FlexProjectionReader {
414
414
  signalArg?: AbortSignal,
415
415
  ): Promise<TFlexSession> {
416
416
  const selection = await this.selectCandidate(scopeIdArg, undefined, signalArg);
417
- const records = await this.loadSessionRecords(selection, signalArg);
418
- const record = records.find((entry) => entry.session.sessionId === sessionIdArg);
419
- if (!record) throw new FlexProjectionNotFoundError();
417
+ const record = await this.loadSessionRecord(selection, sessionIdArg, signalArg);
420
418
  return JSON.parse(JSON.stringify(record.session)) as TFlexSession;
421
419
  }
422
420
 
@@ -627,6 +625,35 @@ export class FlexProjectionReader {
627
625
  }).sort(compareNewestSession);
628
626
  }
629
627
 
628
+ /**
629
+ * The record the selected publication lists for one session: looked up through the session
630
+ * index among the records the publication lists, so it reads that session's records only, and
631
+ * proven against its manifest.
632
+ */
633
+ private async loadSessionRecord(
634
+ selectionArg: IFlexCandidateSelection,
635
+ sessionIdArg: string,
636
+ signalArg?: AbortSignal,
637
+ ): Promise<IFlexPublicSessionRecordDocument> {
638
+ const sessionRecords = selectionArg.manifest.sessionRecords;
639
+ const models = await this.models.publicSessions.getOffsetPage({
640
+ controllerId: this.controllerId,
641
+ scopeId: selectionArg.head.storageKey,
642
+ 'session.sessionId': sessionIdArg,
643
+ id: { $in: sessionRecords.map((record) => record.id) },
644
+ }, this.pageOptions(2, signalArg));
645
+ // A publication lists one record per session.
646
+ if (models.length > 1) throw new FlexProjectionFormatError();
647
+ const [model] = models;
648
+ if (!model) throw new FlexProjectionNotFoundError();
649
+ const document = projectSessionRecord(model);
650
+ assertFlexPublicSessionRecordDocument(document);
651
+ const manifest = sessionRecords.find((record) => record.id === document.id);
652
+ if (!manifest || document.session.sessionId !== sessionIdArg) throw new FlexProjectionFormatError();
653
+ verifyRecordManifest(document, manifest);
654
+ return document;
655
+ }
656
+
630
657
  /**
631
658
  * The transcript the selected publication names for a session, proven by its id; a session
632
659
  * without messages has none.