agl 40.5.1 → 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 (137) hide show
  1. package/changelog.md +43 -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.aglhome.d.ts +5 -0
  9. package/dist_ts/classes.aglhome.js +2 -1
  10. package/dist_ts/classes.authstore.js +5 -1
  11. package/dist_ts/classes.controller.d.ts +8 -0
  12. package/dist_ts/classes.controller.js +119 -22
  13. package/dist_ts/classes.controllereventdelivery.d.ts +72 -0
  14. package/dist_ts/classes.controllereventdelivery.js +233 -0
  15. package/dist_ts/classes.flexmodels.public.js +4 -1
  16. package/dist_ts/classes.flexprojectionreader.d.ts +6 -0
  17. package/dist_ts/classes.flexprojectionreader.js +29 -5
  18. package/dist_ts/classes.flexservice.d.ts +2 -1
  19. package/dist_ts/classes.flexservice.js +34 -6
  20. package/dist_ts/classes.flexstore.d.ts +41 -4
  21. package/dist_ts/classes.flexstore.js +266 -203
  22. package/dist_ts/classes.inferencegateway.d.ts +35 -1
  23. package/dist_ts/classes.inferencegateway.js +86 -26
  24. package/dist_ts/classes.inferenceservice.d.ts +4 -2
  25. package/dist_ts/classes.inferenceservice.js +5 -4
  26. package/dist_ts/classes.proxycontrol.d.ts +58 -4
  27. package/dist_ts/classes.proxycontrol.js +23 -44
  28. package/dist_ts/classes.proxycontrolclient.d.ts +15 -5
  29. package/dist_ts/classes.proxycontrolclient.js +67 -17
  30. package/dist_ts/classes.proxydaemon.d.ts +22 -3
  31. package/dist_ts/classes.proxydaemon.js +73 -12
  32. package/dist_ts/classes.proxyleaseholder.d.ts +43 -14
  33. package/dist_ts/classes.proxyleaseholder.js +66 -22
  34. package/dist_ts/classes.proxystore.d.ts +9 -0
  35. package/dist_ts/classes.proxystore.js +15 -7
  36. package/dist_ts/classes.ticketnotifier.js +4 -2
  37. package/dist_ts/classes.ticketstore.d.ts +50 -2
  38. package/dist_ts/classes.ticketstore.js +195 -78
  39. package/dist_ts/classes.workercallerbindings.d.ts +66 -0
  40. package/dist_ts/classes.workercallerbindings.js +211 -0
  41. package/dist_ts/classes.workercallers.d.ts +63 -2
  42. package/dist_ts/classes.workercallers.js +430 -65
  43. package/dist_ts/driver/classes.drivereventrelay.d.ts +13 -1
  44. package/dist_ts/driver/classes.drivereventrelay.js +99 -6
  45. package/dist_ts/driver/functions.driverinstructions.d.ts +1 -1
  46. package/dist_ts/driver/functions.driverinstructions.js +1 -1
  47. package/dist_ts/driver/functions.drivertools.d.ts +11 -2
  48. package/dist_ts/driver/functions.drivertools.js +19 -9
  49. package/dist_ts/fleet/classes.boardpump.d.ts +8 -2
  50. package/dist_ts/fleet/classes.boardpump.js +38 -20
  51. package/dist_ts/fleet/classes.ticketeffects.d.ts +17 -2
  52. package/dist_ts/fleet/classes.ticketeffects.js +16 -10
  53. package/dist_ts/fleet/classes.workerlaunches.d.ts +12 -5
  54. package/dist_ts/fleet/classes.workerlaunches.js +25 -19
  55. package/dist_ts/fleet/classes.workers.d.ts +21 -0
  56. package/dist_ts/fleet/classes.workers.js +110 -17
  57. package/dist_ts/fleet/functions.workervalidation.js +2 -2
  58. package/dist_ts/functions.flexprivatesnapshot.d.ts +102 -0
  59. package/dist_ts/functions.flexprivatesnapshot.js +461 -0
  60. package/dist_ts/functions.inferencecli.d.ts +3 -2
  61. package/dist_ts/functions.inferencecli.js +67 -5
  62. package/dist_ts/functions.inferencecliarguments.d.ts +6 -2
  63. package/dist_ts/functions.inferencecliarguments.js +29 -2
  64. package/dist_ts/functions.inferencelaunch.d.ts +24 -0
  65. package/dist_ts/functions.inferencelaunch.js +42 -2
  66. package/dist_ts/functions.mcptickettools.d.ts +3 -0
  67. package/dist_ts/functions.mcptickettools.js +2 -2
  68. package/dist_ts/functions.proxycontract.d.ts +11 -0
  69. package/dist_ts/functions.proxycontract.js +12 -1
  70. package/dist_ts/functions.proxysocket.d.ts +6 -0
  71. package/dist_ts/functions.proxysocket.js +46 -0
  72. package/dist_ts/functions.ticketcli.d.ts +1 -1
  73. package/dist_ts/functions.ticketcli.js +8 -4
  74. package/dist_ts/functions.ticketvalidation.d.ts +3 -0
  75. package/dist_ts/functions.ticketvalidation.js +7 -4
  76. package/dist_ts/functions.workerprocessproof.d.ts +6 -0
  77. package/dist_ts/functions.workerprocessproof.js +28 -4
  78. package/dist_ts/interfaces.flexipc.d.ts +21 -2
  79. package/dist_ts/interfaces.flexipc.js +33 -16
  80. package/dist_ts_interfaces/fleetworkers.d.ts +6 -0
  81. package/dist_ts_interfaces/interfaces.d.ts +1 -1
  82. package/dist_ts_interfaces/interfaces.js +1 -1
  83. package/dist_ts_interfaces/requests.tickets.d.ts +8 -12
  84. package/dist_ts_interfaces/tickets.d.ts +37 -9
  85. package/dist_ts_interfaces/tickets.js +7 -1
  86. package/dist_ts_interfaces/workercallers.d.ts +43 -4
  87. package/package.json +7 -7
  88. package/readme.md +156 -23
  89. package/ts/00_commitinfo_data.ts +1 -1
  90. package/ts/aglapi/aglapisurface.document.ts +63 -36
  91. package/ts/classes.aglhome.ts +6 -0
  92. package/ts/classes.authstore.ts +4 -0
  93. package/ts/classes.controller.ts +141 -32
  94. package/ts/classes.controllereventdelivery.ts +289 -0
  95. package/ts/classes.flexmodels.public.ts +10 -0
  96. package/ts/classes.flexprojectionreader.ts +30 -3
  97. package/ts/classes.flexservice.ts +33 -5
  98. package/ts/classes.flexstore.ts +381 -246
  99. package/ts/classes.inferencegateway.ts +104 -23
  100. package/ts/classes.inferenceservice.ts +6 -3
  101. package/ts/classes.proxycontrol.ts +76 -39
  102. package/ts/classes.proxycontrolclient.ts +72 -20
  103. package/ts/classes.proxydaemon.ts +85 -15
  104. package/ts/classes.proxyleaseholder.ts +84 -28
  105. package/ts/classes.proxystore.ts +16 -5
  106. package/ts/classes.ticketnotifier.ts +3 -1
  107. package/ts/classes.ticketstore.ts +201 -75
  108. package/ts/classes.workercallerbindings.ts +147 -0
  109. package/ts/classes.workercallers.ts +410 -59
  110. package/ts/driver/classes.drivereventrelay.ts +89 -4
  111. package/ts/driver/functions.driverinstructions.ts +1 -1
  112. package/ts/driver/functions.drivertools.ts +22 -8
  113. package/ts/fleet/classes.boardpump.ts +51 -19
  114. package/ts/fleet/classes.ticketeffects.ts +25 -11
  115. package/ts/fleet/classes.workerlaunches.ts +23 -15
  116. package/ts/fleet/classes.workers.ts +79 -17
  117. package/ts/fleet/functions.workervalidation.ts +1 -1
  118. package/ts/functions.flexprivatesnapshot.ts +601 -0
  119. package/ts/functions.inferencecli.ts +61 -7
  120. package/ts/functions.inferencecliarguments.ts +32 -1
  121. package/ts/functions.inferencelaunch.ts +49 -2
  122. package/ts/functions.mcptickettools.ts +1 -1
  123. package/ts/functions.proxycontract.ts +12 -0
  124. package/ts/functions.proxysocket.ts +39 -0
  125. package/ts/functions.ticketcli.ts +7 -3
  126. package/ts/functions.ticketvalidation.ts +7 -3
  127. package/ts/functions.workerprocessproof.ts +27 -4
  128. package/ts/interfaces.flexipc.ts +50 -21
  129. package/ts_interfaces/fleetworkers.ts +6 -0
  130. package/ts_interfaces/interfaces.ts +1 -1
  131. package/ts_interfaces/requests.tickets.ts +9 -6
  132. package/ts_interfaces/tickets.ts +32 -1
  133. package/ts_interfaces/workercallers.ts +32 -4
  134. package/ts_web/00_commitinfo_data.ts +1 -1
  135. package/ts_web/elements.harnesscontrollerapp.ts +19 -8
  136. package/ts_web/elements.ticketboard.ts +16 -4
  137. package/ts_web/settings.general.ts +10 -4
@@ -4,6 +4,7 @@ import {
4
4
  type ITicketChanges, type ITicketCreate, type ITicketFilters, type ITicketMove, type ITicketPerson, type ITicketQuestion,
5
5
  type TTicketLink, type TTicketOperation, type TTicketRequest, type TTicketResponse,
6
6
  type TTicketFleetEvent, type ITicketFleetEventResult, type ITicketComment, type TTicketData, type ITicketOpenQuestion,
7
+ type ITicketDetail, type ITicketGet, ticketHistoryPage,
7
8
  } from '../ts_interfaces/tickets.js';
8
9
  import {
9
10
  TicketModel, TicketBoardModel, TicketCommentModel, TicketActivityModel, TicketQuestionModel, TicketFleetEventModel,
@@ -15,6 +16,11 @@ type TTicketStored = NonNullable<Awaited<ReturnType<typeof TicketModel.exact.fin
15
16
  type TSession = plugins.nosqldb.SmartdataSession;
16
17
  const workerColumns = new Set(['ready', 'in_progress', 'needs_input', 'review']);
17
18
  const mutableOperations = new Set<TTicketOperation>(['create', 'update', 'move', 'comment', 'link', 'ask', 'answer']);
19
+ /**
20
+ * Activity records a history page reads at a time, newest first: a page reads at most this many records beyond the
21
+ * entries it returns, whatever its `limit`, so a page cut by the byte budget does not read `limit` records.
22
+ */
23
+ export const ticketHistoryBatch = 16;
18
24
 
19
25
  /** One change a committed ticket transaction made, or an edit it refused as stale. */
20
26
  export interface ITicketChange {
@@ -35,10 +41,16 @@ export interface ITicketObservation {
35
41
  revision?: number;
36
42
  changes: ITicketChange[];
37
43
  }
44
+ /** A ticket with its whole history and all its questions, as one transaction read them; stored activity keeps its comments. */
45
+ export interface ITicketHistory {
46
+ ticket: ITicket;
47
+ activity: ITicketActivity[];
48
+ questions: ITicketQuestion[];
49
+ }
38
50
  /** What one transaction attempt read and changed. A retried attempt starts a new one. */
39
51
  interface ITicketTrack {
40
52
  ticketIds: string[];
41
- /** Open questions per ticket id, read inside the transaction; the AutoLabel facts. */
53
+ /** Open questions per ticket id, read inside the transaction for the tickets it shows; the AutoLabel facts. */
42
54
  openQuestions: Map<string, number>;
43
55
  changes: ITicketChange[];
44
56
  origin?: string;
@@ -101,9 +113,8 @@ export class TicketStore {
101
113
  if (fenced.status !== 'transitioned') throw new TicketError('concurrent_change', 'The board changed concurrently.');
102
114
  const now = new Date().toISOString();
103
115
  const revision = board.revision + 1;
104
- track = await this.track(transactionArg, originArg);
105
- let tickets = await this.allTickets(transactionArg);
106
- if (!tickets.some(record => record.id === event.ticketKey)) {
116
+ track = this.track(originArg);
117
+ if (!await TicketModel.exact.findStoredOne({ id: event.ticketKey }, { session: transactionArg })) {
107
118
  throw new TicketError('ticket_not_found', ticketErrorMessages.ticket_not_found);
108
119
  }
109
120
  if (event.kind !== 'comment') {
@@ -111,16 +122,14 @@ export class TicketStore {
111
122
  ? { kind: 'branch', repo: event.repo, branch: event.branch, sha: event.sha }
112
123
  : { kind: 'release', package: event.package, version: event.version };
113
124
  await this.perform('link', { ticketId: event.ticketKey, link }, event.actor, transactionArg,
114
- tickets, now, board.sequence + 1, revision, track);
115
- tickets = await this.allTickets(transactionArg);
125
+ now, board.sequence + 1, revision, track);
116
126
  await this.perform('move', { ticketId: event.ticketKey, columnId: event.kind }, event.actor, transactionArg,
117
- tickets, now, board.sequence + 1, revision, track);
118
- tickets = await this.allTickets(transactionArg);
127
+ now, board.sequence + 1, revision, track);
119
128
  }
120
129
  let comment: ITicketComment | undefined;
121
130
  if (event.body !== undefined) {
122
131
  const response = await this.perform('comment', { ticketId: event.ticketKey, body: event.body }, event.actor,
123
- transactionArg, tickets, now, board.sequence + 1, revision, track) as TTicketResponse<'comment'>;
132
+ transactionArg, now, board.sequence + 1, revision, track) as TTicketResponse<'comment'>;
124
133
  comment = response.comment;
125
134
  }
126
135
  const current = await TicketModel.exact.findStoredOne({ id: event.ticketKey }, { session: transactionArg });
@@ -219,11 +228,10 @@ export class TicketStore {
219
228
  } }, { session: transactionArg });
220
229
  if (fenced.status !== 'transitioned') throw new TicketError('concurrent_change', 'The board changed concurrently.');
221
230
  }
222
- const tickets = await this.allTickets(transactionArg);
223
231
  const now = new Date().toISOString();
224
232
  const revision = board.revision + (mutableOperations.has(methodArg) ? 1 : 0);
225
- track = await this.track(transactionArg);
226
- const response = await this.perform(methodArg, request, actor, transactionArg, tickets, now,
233
+ track = this.track();
234
+ const response = await this.perform(methodArg, request, actor, transactionArg, now,
227
235
  board.sequence + 1, revision, track);
228
236
  return { response, revision, ticketIds: track.ticketIds };
229
237
  }, { timeoutMS: 20_000, ...(signalArg ? { signal: signalArg } : {}) });
@@ -252,17 +260,53 @@ export class TicketStore {
252
260
  try { this.observer(structuredClone(observationArg)); } catch { /* observers report their own failures */ }
253
261
  }
254
262
 
255
- private async track(sessionArg: TSession, originArg?: string): Promise<ITicketTrack> {
256
- const openQuestions = new Map<string, number>();
263
+ private track(originArg?: string): ITicketTrack {
264
+ return { ticketIds: [], openQuestions: new Map(), changes: [], ...(originArg ? { origin: originArg } : {}) };
265
+ }
266
+
267
+ /**
268
+ * Reads the open questions of `ticketIdsArg`, or of every ticket, into the track as the transaction sees them now,
269
+ * after its own writes. Only the tickets a response shows are counted.
270
+ */
271
+ private async countOpenQuestions(trackArg: ITicketTrack, sessionArg: TSession, ticketIdsArg?: string[]): Promise<void> {
272
+ const chunks: (string[] | undefined)[] = [];
273
+ if (ticketIdsArg === undefined) { trackArg.openQuestions.clear(); chunks.push(undefined); }
274
+ else {
275
+ const ids = [...new Set(ticketIdsArg)];
276
+ for (const id of ids) trackArg.openQuestions.set(id, 0);
277
+ for (let index = 0; index < ids.length; index += 500) chunks.push(ids.slice(index, index + 500));
278
+ }
279
+ for (const chunk of chunks) {
280
+ let after: string | undefined;
281
+ while (true) {
282
+ const page = await TicketQuestionModel.exact.findStored({ filter: { status: 'open', ...(chunk ? { ticketId: { $in: chunk } } : {}),
283
+ ...(after ? { id: { $gt: after } } : {}) }, sort: { id: 1 }, limit: 500, session: sessionArg });
284
+ for (const record of page) trackArg.openQuestions.set(record.ticketId, (trackArg.openQuestions.get(record.ticketId) ?? 0) + 1);
285
+ if (page.length < 500) break;
286
+ after = page.at(-1)!.id;
287
+ }
288
+ }
289
+ }
290
+
291
+ private async ticket(ticketIdArg: string, sessionArg: TSession): Promise<TTicketStored | null> {
292
+ return await TicketModel.exact.findStoredOne({ id: ticketIdArg }, { session: sessionArg });
293
+ }
294
+
295
+ /** The tickets of one column, by id; the board's `ticket_column_order` index serves it. */
296
+ private async columnTickets(columnIdArg: ITicket['columnId'], sessionArg: TSession): Promise<TTicketStored[]> {
297
+ return await this.paged((afterArg) => TicketModel.exact.findStored({ filter: { 'ticket.columnId': columnIdArg,
298
+ ...(afterArg ? { id: { $gt: afterArg } } : {}) }, sort: { id: 1 }, limit: 500, session: sessionArg }));
299
+ }
300
+
301
+ private async paged<TRecord extends { id: string }>(pageArg: (afterArg: string | undefined) => Promise<TRecord[]>): Promise<TRecord[]> {
302
+ const records: TRecord[] = [];
257
303
  let after: string | undefined;
258
304
  while (true) {
259
- const page = await TicketQuestionModel.exact.findStored({ filter: { status: 'open', ...(after ? { id: { $gt: after } } : {}) },
260
- sort: { id: 1 }, limit: 500, session: sessionArg });
261
- for (const record of page) openQuestions.set(record.ticketId, (openQuestions.get(record.ticketId) ?? 0) + 1);
262
- if (page.length < 500) break;
305
+ const page = await pageArg(after);
306
+ records.push(...page);
307
+ if (page.length < 500) return records;
263
308
  after = page.at(-1)!.id;
264
309
  }
265
- return { ticketIds: [], openQuestions, changes: [], ...(originArg ? { origin: originArg } : {}) };
266
310
  }
267
311
 
268
312
  private async questions(ticketIdArg: string, sessionArg: TSession): Promise<ITicketQuestion[]> {
@@ -280,15 +324,8 @@ export class TicketStore {
280
324
  }
281
325
 
282
326
  private async allTickets(sessionArg: TSession): Promise<TTicketStored[]> {
283
- const records: TTicketStored[] = [];
284
- let after: string | undefined;
285
- while (true) {
286
- const page = await TicketModel.exact.findStored({ filter: after ? { id: { $gt: after } } : {},
287
- sort: { id: 1 }, limit: 500, session: sessionArg });
288
- records.push(...page);
289
- if (page.length < 500) return records;
290
- after = page.at(-1)!.id;
291
- }
327
+ return await this.paged((afterArg) => TicketModel.exact.findStored({ filter: afterArg ? { id: { $gt: afterArg } } : {},
328
+ sort: { id: 1 }, limit: 500, session: sessionArg }));
292
329
  }
293
330
 
294
331
  private visible(ticketArg: TTicketData, actorArg: ITicketActor | undefined, trackArg: ITicketTrack): ITicket {
@@ -302,6 +339,12 @@ export class TicketStore {
302
339
  } };
303
340
  }
304
341
 
342
+ /** `visible` with the ticket's open questions read now. */
343
+ private async shown(ticketArg: TTicketData, actorArg: ITicketActor | undefined, sessionArg: TSession, trackArg: ITicketTrack): Promise<ITicket> {
344
+ await this.countOpenQuestions(trackArg, sessionArg, [ticketArg.id]);
345
+ return this.visible(ticketArg, actorArg, trackArg);
346
+ }
347
+
305
348
  private requireEdit(ticketArg: TTicketData, actorArg: ITicketActor): void {
306
349
  if (actorArg.role !== 'worker') return;
307
350
  if (!ticketAssignedTo(ticketArg, actorArg)) {
@@ -318,14 +361,19 @@ export class TicketStore {
318
361
  }
319
362
  }
320
363
 
321
- private parent(ticketArg: TTicketData, ticketsArg: TTicketStored[]): void {
364
+ /** Checks the ticket's parent as the transaction holds it. */
365
+ private async parent(ticketArg: TTicketData, sessionArg: TSession): Promise<void> {
366
+ this.requireParent(ticketArg, ticketArg.parentId ? (await this.ticket(ticketArg.parentId, sessionArg))?.ticket : undefined);
367
+ }
368
+
369
+ /** `parentArg` is the ticket `ticketArg.parentId` names, undefined when none exists. */
370
+ private requireParent(ticketArg: TTicketData, parentArg: TTicketData | undefined): void {
322
371
  if (!ticketArg.parentId) {
323
372
  if (ticketArg.kind === 'subtask') throw new TicketError('ticket_parent_required', 'A subtask requires a task parent.');
324
373
  return;
325
374
  }
326
- const parent = ticketsArg.find((recordArg) => recordArg.id === ticketArg.parentId)?.ticket;
327
- if (!parent || parent.project !== ticketArg.project || ticketArg.id === parent.id
328
- || ticketArg.kind === 'epic' || (ticketArg.kind === 'task' ? parent.kind !== 'epic' : parent.kind !== 'task')) {
375
+ if (!parentArg || parentArg.project !== ticketArg.project || ticketArg.id === parentArg.id
376
+ || ticketArg.kind === 'epic' || (ticketArg.kind === 'task' ? parentArg.kind !== 'epic' : parentArg.kind !== 'task')) {
329
377
  throw new TicketError('ticket_parent_invalid', 'Parents must be an epic for a task or a task for a subtask, in the same project.');
330
378
  }
331
379
  }
@@ -367,37 +415,101 @@ export class TicketStore {
367
415
  return next;
368
416
  }
369
417
 
370
- private async history(ticketIdArg: string, sessionArg: TSession) {
371
- const comments = [] as Awaited<ReturnType<typeof TicketCommentModel.exact.findStored>>;
372
- const activity = [] as Awaited<ReturnType<typeof TicketActivityModel.exact.findStored>>;
373
- let after: string | undefined;
374
- while (true) {
375
- const page = await TicketCommentModel.exact.findStored({ filter: { ticketId: ticketIdArg,
376
- ...(after ? { id: { $gt: after } } : {}) }, sort: { id: 1 }, limit: 500, session: sessionArg });
377
- comments.push(...page);
378
- if (page.length < 500) break;
379
- after = page.at(-1)!.id;
418
+ private async allComments(ticketIdArg: string, sessionArg: TSession): Promise<ITicketComment[]> {
419
+ return structuredClone((await this.paged((afterArg) => TicketCommentModel.exact.findStored({ filter: { ticketId: ticketIdArg,
420
+ ...(afterArg ? { id: { $gt: afterArg } } : {}) }, sort: { id: 1 }, limit: 500, session: sessionArg })))
421
+ .map((recordArg) => recordArg.comment));
422
+ }
423
+
424
+ private async allActivity(ticketIdArg: string, sessionArg: TSession): Promise<ITicketActivity[]> {
425
+ return structuredClone((await this.paged((afterArg) => TicketActivityModel.exact.findStored({ filter: { ticketId: ticketIdArg,
426
+ ...(afterArg ? { id: { $gt: afterArg } } : {}) }, sort: { id: 1 }, limit: 500, session: sessionArg })))
427
+ .map((recordArg) => recordArg.activity));
428
+ }
429
+
430
+ /**
431
+ * One page of a ticket's history, oldest first: walking back from the newest entry older than `before`, at most
432
+ * `limit` entries and no more than `ticketHistoryPage.bytes` of entries plus the comments they name as returned
433
+ * (always at least one entry), and those comments. In the page an entry names its comment by id; the comment comes
434
+ * once. The walk reads `ticketHistoryBatch` records at a time and stops at the first record the page leaves out,
435
+ * which is the proof that older entries remain (`historyBefore`).
436
+ *
437
+ * A comment comes from the copy its `commented` entry embeds: comments are insert-only, and the one place that
438
+ * writes a comment (`perform`'s `comment`) writes the entry with that same comment in the same transaction.
439
+ */
440
+ private async historyPage(requestArg: ITicketGet, sessionArg: TSession): Promise<Pick<ITicketDetail, 'comments' | 'activity' | 'historyBefore'>> {
441
+ const limit = requestArg.limit ?? ticketHistoryPage.default;
442
+ const activity: ITicketActivity[] = [];
443
+ const comments: ITicketComment[] = [];
444
+ let bytes = 0;
445
+ let cursor = requestArg.before;
446
+ let cut = false;
447
+ let more = true;
448
+ while (more && !cut) {
449
+ // One record past `limit` at most: it tells whether older entries remain.
450
+ const wanted = Math.min(ticketHistoryBatch, limit + 1 - activity.length);
451
+ const batch = await TicketActivityModel.exact.findStored({ filter: { ticketId: requestArg.ticketId,
452
+ ...(cursor === undefined ? {} : { id: { $lt: cursor } }) }, sort: { id: -1 }, limit: wanted, session: sessionArg });
453
+ more = batch.length === wanted;
454
+ cursor = batch.at(-1)?.id;
455
+ for (const record of batch) {
456
+ const entry = structuredClone(record.activity);
457
+ const comment = entry.action === 'commented' ? entry.changes.comment?.after as ITicketComment | undefined : undefined;
458
+ const shown = comment ? { ...entry, changes: { ...entry.changes, comment: { after: { id: comment.id } } } } : entry;
459
+ const size = Buffer.byteLength(JSON.stringify(shown)) + (comment ? Buffer.byteLength(JSON.stringify(comment)) : 0);
460
+ if (activity.length === limit || (activity.length && bytes + size > ticketHistoryPage.bytes)) {
461
+ cut = true;
462
+ break;
463
+ }
464
+ bytes += size;
465
+ activity.unshift(shown);
466
+ if (comment) comments.push(comment);
467
+ }
380
468
  }
381
- after = undefined;
382
- while (true) {
383
- const page = await TicketActivityModel.exact.findStored({ filter: { ticketId: ticketIdArg,
384
- ...(after ? { id: { $gt: after } } : {}) }, sort: { id: 1 }, limit: 500, session: sessionArg });
385
- activity.push(...page);
386
- if (page.length < 500) break;
387
- after = page.at(-1)!.id;
469
+ return { comments: comments.sort((leftArg, rightArg) => (leftArg.id < rightArg.id ? -1 : leftArg.id > rightArg.id ? 1 : 0)), activity,
470
+ ...(cut ? { historyBefore: activity[0]!.id } : {}) };
471
+ }
472
+
473
+ /**
474
+ * A ticket with its whole history and all its questions, read in one transaction without the rest of the board.
475
+ * The board pump reads a ticket this way only when its revision moved.
476
+ */
477
+ public async history(ticketIdArg: string, signalArg?: AbortSignal): Promise<ITicketHistory> {
478
+ const ticketId = assertTicketRequest('get', { ticketId: ticketIdArg }).ticketId;
479
+ const session = TicketBoardModel.collection.smartdataDb.createSession();
480
+ try {
481
+ const history = await session.withTransaction(async (transactionArg) => {
482
+ const current = await this.ticket(ticketId, transactionArg);
483
+ if (!current) throw new TicketError('ticket_not_found', ticketErrorMessages.ticket_not_found);
484
+ const questions = await this.questions(ticketId, transactionArg);
485
+ const track = this.track();
486
+ track.openQuestions.set(ticketId, questions.filter((questionArg) => questionArg.status === 'open').length);
487
+ return { ticket: this.visible(current.ticket, undefined, track), activity: await this.allActivity(ticketId, transactionArg), questions };
488
+ }, { timeoutMS: 20_000, ...(signalArg ? { signal: signalArg } : {}) });
489
+ if (!history) throw new Error('The ticket history transaction returned no result.');
490
+ return history;
491
+ } finally {
492
+ await session.close();
388
493
  }
389
- return structuredClone({ comments: comments.map((recordArg) => recordArg.comment), activity: activity.map((recordArg) => recordArg.activity) });
390
494
  }
391
495
 
496
+ /**
497
+ * Each operation reads what it decides on and nothing more: the listing operations read the whole board and every
498
+ * open question; create the parent and the destination column's count; move the source and destination columns;
499
+ * update the ticket, its parent and, when the project changes, its children; every other operation one ticket, and
500
+ * get one page of its history or, with `commentId`, that one comment. The board document's revision transition in the caller orders every mutation.
501
+ */
392
502
  private async perform(methodArg: TTicketOperation, requestArg: TTicketRequest<TTicketOperation>, actorArg: ITicketActor | undefined,
393
- sessionArg: TSession, ticketsArg: TTicketStored[], nowArg: string, sequenceArg: number,
503
+ sessionArg: TSession, nowArg: string, sequenceArg: number,
394
504
  revisionArg: number, trackArg: ITicketTrack): Promise<unknown> {
395
505
  if (methodArg === 'list' || methodArg === 'board' || methodArg === 'search') {
396
506
  const filters: ITicketFilters = methodArg === 'search'
397
507
  ? { text: (requestArg as TTicketRequest<'search'>).query, label: (requestArg as TTicketRequest<'search'>).label }
398
508
  : requestArg as ITicketFilters;
399
509
  const text = filters.text?.toLocaleLowerCase();
400
- const tickets = ticketsArg.map((recordArg) => this.visible(recordArg.ticket, actorArg, trackArg)).filter((ticketArg) => (
510
+ const all = await this.allTickets(sessionArg);
511
+ await this.countOpenQuestions(trackArg, sessionArg);
512
+ const tickets = all.map((recordArg) => this.visible(recordArg.ticket, actorArg, trackArg)).filter((ticketArg) => (
401
513
  (!filters.mine || (actorArg !== undefined && ticketAssignedTo(ticketArg, actorArg)))
402
514
  && (!filters.project || ticketArg.project === filters.project)
403
515
  && (!filters.status || ticketArg.columnId === filters.status)
@@ -426,28 +538,38 @@ export class TicketStore {
426
538
  const id = `AGL-${sequenceArg}`;
427
539
  const ticket: ITicket = { id, key: id, revision: 1, project: input.project, kind: input.kind ?? 'task', title: input.title,
428
540
  description: input.description ?? '', ...(input.parentId ? { parentId: input.parentId } : {}), columnId,
429
- order: ticketsArg.filter((recordArg) => recordArg.ticket.columnId === columnId).length,
541
+ order: await TicketModel.exact.count({ 'ticket.columnId': columnId }, { session: sessionArg }),
430
542
  priority: input.priority ?? 'normal', ...(input.assignee ? { assignee: input.assignee } : {}), labels: input.labels ?? [], links: [],
431
543
  source: { kind: actorArg.role === 'owner' ? 'owner_words' : actorArg.role, text: input.sourceText ?? input.description ?? input.title,
432
544
  actor: actorArg, attachments: input.attachments ?? [] }, createdBy: actorArg, updatedBy: actorArg, createdAt: nowArg, updatedAt: nowArg };
433
- this.parent(ticket, ticketsArg);
545
+ await this.parent(ticket, sessionArg);
434
546
  this.requireReview(ticket);
435
547
  const result = await TicketModel.exact.insert({ id, ticket }, { session: sessionArg });
436
548
  if (result.status === 'conflict') throw new Error('Global ticket sequence conflict.');
437
549
  await this.activity(ticket, actorArg, sessionArg, 'created', { ticket: { after: ticket } }, nowArg, revisionArg, trackArg);
438
550
  trackArg.ticketIds.push(id);
439
- return { ticket: this.visible(ticket, actorArg, trackArg) };
551
+ return { ticket: await this.shown(ticket, actorArg, sessionArg, trackArg) };
440
552
  }
441
553
  const ticketId = (requestArg as { ticketId: string }).ticketId;
442
- const current = ticketsArg.find((recordArg) => recordArg.id === ticketId);
554
+ const current = await this.ticket(ticketId, sessionArg);
443
555
  if (!current) throw new TicketError('ticket_not_found', 'The requested ticket does not exist.');
444
556
  const ticket = structuredClone(current.ticket);
445
557
  if (methodArg === 'get') {
446
- return { ticket: this.visible(ticket, actorArg, trackArg), ...await this.history(ticketId, sessionArg),
447
- questions: await this.questions(ticketId, sessionArg) };
558
+ const questions = await this.questions(ticketId, sessionArg);
559
+ trackArg.openQuestions.set(ticketId, questions.filter((questionArg) => questionArg.status === 'open').length);
560
+ const { commentId } = requestArg as ITicketGet;
561
+ if (commentId !== undefined) {
562
+ const stored = await TicketCommentModel.exact.findStoredOne({ id: commentId, ticketId }, { session: sessionArg });
563
+ if (!stored) throw new TicketError('ticket_comment_not_found', ticketErrorMessages.ticket_comment_not_found);
564
+ return { ticket: this.visible(ticket, actorArg, trackArg), comments: [structuredClone(stored.comment)], activity: [],
565
+ questions } satisfies ITicketDetail;
566
+ }
567
+ const { comments, activity, historyBefore } = await this.historyPage(requestArg as ITicketGet, sessionArg);
568
+ return { ticket: this.visible(ticket, actorArg, trackArg), comments, activity, questions,
569
+ ...(historyBefore === undefined ? {} : { historyBefore }) } satisfies ITicketDetail;
448
570
  }
449
571
  if (!actorArg) throw new TicketError('ticket_identity_required', ticketErrorMessages.ticket_identity_required);
450
- if (methodArg === 'comments') return { comments: (await this.history(ticketId, sessionArg)).comments };
572
+ if (methodArg === 'comments') return { comments: await this.allComments(ticketId, sessionArg) };
451
573
  if (methodArg === 'comment') {
452
574
  const input = requestArg as TTicketRequest<'comment'>;
453
575
  const id = `${String(revisionArg).padStart(16, '0')}:${plugins.crypto.randomUUID()}`;
@@ -470,9 +592,8 @@ export class TicketStore {
470
592
  const inserted = await TicketQuestionModel.exact.insert({ id: `${ticketId}:${question.id}`, ticketId, status: 'open', question },
471
593
  { session: sessionArg });
472
594
  if (inserted.status === 'conflict') throw new Error('Ticket question identity conflict.');
473
- trackArg.openQuestions.set(ticketId, (trackArg.openQuestions.get(ticketId) ?? 0) + 1);
474
595
  const saved = await this.save(current, ticket, actorArg, sessionArg, 'asked', nowArg, trackArg, revisionArg, { question: { after: question } });
475
- return { question, ticket: this.visible(saved, actorArg, trackArg) };
596
+ return { question, ticket: await this.shown(saved, actorArg, sessionArg, trackArg) };
476
597
  }
477
598
  if (methodArg === 'answer') {
478
599
  if (actorArg.role === 'worker') throw new TicketError('ticket_answer_forbidden', ticketErrorMessages.ticket_answer_forbidden);
@@ -490,7 +611,7 @@ export class TicketStore {
490
611
  if (JSON.stringify([question.answer.choices, question.answer.text]) !== JSON.stringify([choices, input.text])) {
491
612
  throw new TicketError('ticket_question_answered', ticketErrorMessages.ticket_question_answered);
492
613
  }
493
- return { question, ticket: this.visible(ticket, actorArg, trackArg) };
614
+ return { question, ticket: await this.shown(ticket, actorArg, sessionArg, trackArg) };
494
615
  }
495
616
  question.status = 'answered';
496
617
  question.answer = { choices, ...(input.text === undefined ? {} : { text: input.text }), answeredBy: actorArg, answeredAt: nowArg };
@@ -499,10 +620,9 @@ export class TicketStore {
499
620
  modelArg.question = question;
500
621
  } }, { session: sessionArg });
501
622
  if (transitioned.status !== 'transitioned') throw new TicketError('concurrent_change', 'The question changed concurrently.');
502
- trackArg.openQuestions.set(ticketId, Math.max(0, (trackArg.openQuestions.get(ticketId) ?? 0) - 1));
503
623
  const saved = await this.save(current, ticket, actorArg, sessionArg, 'answered', nowArg, trackArg, revisionArg,
504
624
  { question: { before: stored.question, after: question } });
505
- return { question, ticket: this.visible(saved, actorArg, trackArg) };
625
+ return { question, ticket: await this.shown(saved, actorArg, sessionArg, trackArg) };
506
626
  }
507
627
  this.requireEdit(ticket, actorArg);
508
628
  if (methodArg === 'update') {
@@ -523,29 +643,34 @@ export class TicketStore {
523
643
  else if (assignee !== undefined) ticket.assignee = assignee;
524
644
  if (parentId === null) delete ticket.parentId;
525
645
  else if (parentId !== undefined) ticket.parentId = parentId;
526
- this.parent(ticket, ticketsArg);
527
- for (const child of ticketsArg.filter((recordArg) => recordArg.ticket.parentId === ticketId)) {
528
- this.parent(child.ticket, ticketsArg.map((recordArg) => recordArg.id === ticketId ? { ...recordArg, ticket } : recordArg));
646
+ await this.parent(ticket, sessionArg);
647
+ // A child's parent rule reads its parent's kind, which no update changes, and project.
648
+ if (ticket.project !== current.ticket.project) {
649
+ const children = await this.paged((afterArg) => TicketModel.exact.findStored({ filter: { 'ticket.parentId': ticketId,
650
+ ...(afterArg ? { id: { $gt: afterArg } } : {}) }, sort: { id: 1 }, limit: 500, session: sessionArg }));
651
+ for (const child of children) this.requireParent(child.ticket, ticket);
529
652
  }
530
- return { ticket: this.visible(await this.save(current, ticket, actorArg, sessionArg, 'updated', nowArg, trackArg, revisionArg), actorArg, trackArg) };
653
+ return { ticket: await this.shown(await this.save(current, ticket, actorArg, sessionArg, 'updated', nowArg, trackArg, revisionArg),
654
+ actorArg, sessionArg, trackArg) };
531
655
  }
532
656
  if (methodArg === 'link') {
533
657
  const link = (requestArg as { link: TTicketLink }).link;
534
658
  if (!ticket.links.some((existingArg) => JSON.stringify(existingArg) === JSON.stringify(link))) ticket.links.push(link);
535
- return { ticket: this.visible(await this.save(current, ticket, actorArg, sessionArg, 'linked', nowArg, trackArg, revisionArg), actorArg, trackArg) };
659
+ return { ticket: await this.shown(await this.save(current, ticket, actorArg, sessionArg, 'linked', nowArg, trackArg, revisionArg),
660
+ actorArg, sessionArg, trackArg) };
536
661
  }
537
662
  const input = requestArg as ITicketMove;
538
663
  if (actorArg.role === 'worker' && !workerColumns.has(input.columnId)) {
539
664
  throw new TicketError('ticket_worker_column', 'Workers may move only to ready, in_progress, needs_input and review; only driver or owner may land or release.');
540
665
  }
541
666
  if (input.beforeTicketId === ticketId) throw new TicketError('ticket_move_anchor_invalid', 'A ticket cannot be moved before itself.');
542
- const destination = ticketsArg.filter((recordArg) => recordArg.ticket.columnId === input.columnId && recordArg.id !== ticketId)
667
+ const destination = (await this.columnTickets(input.columnId, sessionArg)).filter((recordArg) => recordArg.id !== ticketId)
543
668
  .sort((leftArg, rightArg) => leftArg.ticket.order - rightArg.ticket.order);
544
669
  const index = input.beforeTicketId === undefined ? destination.length : destination.findIndex((recordArg) => recordArg.id === input.beforeTicketId);
545
670
  if (index < 0) throw new TicketError('ticket_move_anchor_invalid', 'The before-ticket must belong to the destination column.');
546
671
  destination.splice(index, 0, current);
547
- const source = ticket.columnId === input.columnId ? [] : ticketsArg
548
- .filter((recordArg) => recordArg.ticket.columnId === ticket.columnId && recordArg.id !== ticketId)
672
+ const source = ticket.columnId === input.columnId ? [] : (await this.columnTickets(ticket.columnId, sessionArg))
673
+ .filter((recordArg) => recordArg.id !== ticketId)
549
674
  .sort((leftArg, rightArg) => leftArg.ticket.order - rightArg.ticket.order);
550
675
  const changed: ITicket[] = [];
551
676
  for (const group of [destination, source]) {
@@ -553,10 +678,11 @@ export class TicketStore {
553
678
  const columnId = group === destination ? input.columnId : ticket.columnId;
554
679
  if (record.ticket.order === order && record.ticket.columnId === columnId) continue;
555
680
  const next = { ...record.ticket, columnId, order };
556
- changed.push(this.visible(await this.save(record, next, actorArg, sessionArg,
557
- record.id === ticketId ? 'moved' : 'reordered', nowArg, trackArg, revisionArg), actorArg, trackArg));
681
+ changed.push(await this.save(record, next, actorArg, sessionArg,
682
+ record.id === ticketId ? 'moved' : 'reordered', nowArg, trackArg, revisionArg));
558
683
  }
559
684
  }
560
- return { tickets: changed };
685
+ await this.countOpenQuestions(trackArg, sessionArg, changed.map((ticketArg) => ticketArg.id));
686
+ return { tickets: changed.map((ticketArg) => this.visible(ticketArg, actorArg, trackArg)) };
561
687
  }
562
688
  }
@@ -0,0 +1,147 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { IWorkerBridgeCommand, IWorkerProcessSnapshot } from './functions.workerprocessproof.js';
3
+
4
+ /** The Claude process a kept binding was last proved against: its PID, start and the digest of its whole snapshot. */
5
+ export interface IWorkerCallerBindingProcess {
6
+ pid: number;
7
+ startTicks: string;
8
+ snapshotDigest: string;
9
+ }
10
+
11
+ /**
12
+ * What the controller keeps of a fleet Claude worker's current caller launch, so a restarted controller proves the
13
+ * worker's bridge again instead of forgetting it. Never a credential, never the launch id or generation themselves:
14
+ * the overlay holds those, and a bridge presents them; the record keeps their digests to find and check the launch.
15
+ */
16
+ export interface IWorkerCallerBindingDocument {
17
+ /** The fleet worker's id: a worker keeps one binding, its current launch's. */
18
+ id: string;
19
+ projectId: string;
20
+ harnessId: 'claude';
21
+ /** SHA-256 digests (base64url) of the launch id and of its generation. */
22
+ launchDigest: string;
23
+ generationDigest: string;
24
+ /** The bridge command approved when the launch was prepared, and the Claude Code executable it was proved against. */
25
+ command: IWorkerBridgeCommand;
26
+ claudeExecutable: string;
27
+ revision: number;
28
+ /** Once bound: the background job, its session, and the job's process when one was proved. */
29
+ binding?: { nativeSessionId: string; jobId: string; process?: IWorkerCallerBindingProcess };
30
+ updateId: string;
31
+ }
32
+ export type TWorkerCallerBindingRecord = Omit<IWorkerCallerBindingDocument, 'updateId'>;
33
+
34
+ /** Where the controller keeps the bindings of fleet Claude launches. */
35
+ export interface IWorkerCallerBindingStore {
36
+ /** The binding kept for the launch whose id has the digest `launchDigestArg`. */
37
+ find(launchDigestArg: string): Promise<TWorkerCallerBindingRecord | undefined>;
38
+ /** Keeps `recordArg` as its worker's binding; another launch's binding of the worker is replaced. */
39
+ save(recordArg: TWorkerCallerBindingRecord): Promise<void>;
40
+ /** Deletes the worker's binding; with `launchDigestArg`, only while it is that launch's. */
41
+ delete(workerIdArg: string, launchDigestArg?: string): Promise<void>;
42
+ }
43
+
44
+ export const workerCallerDigest = (valueArg: string): string => plugins.crypto.createHash('sha256').update(valueArg).digest('base64url');
45
+ export const workerProcessSnapshotDigest = (snapshotArg: IWorkerProcessSnapshot): string => workerCallerDigest(JSON.stringify([
46
+ snapshotArg.pid, snapshotArg.parentPid, snapshotArg.startTicks, snapshotArg.executable, snapshotArg.arguments]));
47
+
48
+ const digest = plugins.z.string().regex(/^[A-Za-z0-9_-]{43}$/u);
49
+ const absolutePath = plugins.z.string().min(2).max(4096).refine((valueArg) => plugins.path.isAbsolute(valueArg) && !valueArg.includes('\0'));
50
+ const documentSchema = plugins.z.object({
51
+ id: plugins.z.string().min(3).max(1024), projectId: plugins.z.string().regex(/^[A-Za-z0-9_.:-]{1,256}$/u),
52
+ harnessId: plugins.z.literal('claude'), launchDigest: digest, generationDigest: digest,
53
+ command: plugins.z.object({ executable: absolutePath, entry: absolutePath,
54
+ arguments: plugins.z.array(plugins.z.string().max(4096)).max(64) }).strict(),
55
+ claudeExecutable: absolutePath, revision: plugins.z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
56
+ binding: plugins.z.object({ nativeSessionId: plugins.z.string().uuid(), jobId: plugins.z.string().regex(/^[A-Za-z0-9_.:-]{1,256}$/u),
57
+ process: plugins.z.object({ pid: plugins.z.number().int().min(2), startTicks: plugins.z.string().regex(/^\d{1,32}$/u),
58
+ snapshotDigest: digest }).strict().optional() }).strict().optional(),
59
+ updateId: plugins.z.string().uuid(),
60
+ }).strict();
61
+
62
+ @plugins.nosqldb.namedIndex({ name: 'worker_caller_launch', key: { launchDigest: 1 }, options: { unique: true } })
63
+ @plugins.nosqldb.managed({ collectionName: 'agl_worker_caller_bindings' })
64
+ @plugins.nosqldb.exactPersistence({ assertDocument: (input: unknown) => {
65
+ const doc = documentSchema.parse(input);
66
+ if (!doc.id.startsWith(`${doc.projectId}:`)) throw new Error('Worker caller binding identity mismatch.');
67
+ if (doc.binding && doc.revision < 1) throw new Error('Worker caller binding revision mismatch.');
68
+ } })
69
+ export class WorkerCallerBindingModel extends plugins.nosqldb.SmartDataDbDoc<WorkerCallerBindingModel, IWorkerCallerBindingDocument> {
70
+ declare static exact: plugins.nosqldb.TExact<WorkerCallerBindingModel>;
71
+ @plugins.nosqldb.unI() public id!: string;
72
+ @plugins.nosqldb.svDb() public projectId!: string;
73
+ @plugins.nosqldb.svDb() public harnessId!: 'claude';
74
+ @plugins.nosqldb.svDb() public launchDigest!: string;
75
+ @plugins.nosqldb.svDb() public generationDigest!: string;
76
+ @plugins.nosqldb.svDb() public command!: IWorkerBridgeCommand;
77
+ @plugins.nosqldb.svDb() public claudeExecutable!: string;
78
+ @plugins.nosqldb.svDb() public revision!: number;
79
+ @plugins.nosqldb.svDb() public binding?: IWorkerCallerBindingDocument['binding'];
80
+ @plugins.nosqldb.svDb() public updateId!: string;
81
+ }
82
+
83
+ const ambiguous = (errorArg: unknown): boolean => errorArg instanceof plugins.nosqldb.SmartdataExactPersistenceError && errorArg.code === 'ambiguous_write';
84
+ const record = (documentArg: IWorkerCallerBindingDocument): TWorkerCallerBindingRecord => {
85
+ const { updateId: _updateId, ...rest } = structuredClone(documentArg);
86
+ return rest;
87
+ };
88
+
89
+ /** The kept bindings in the controller's store; the auth store owns the model manager and database. Writes run one at a time. */
90
+ export class WorkerCallerBindingStore implements IWorkerCallerBindingStore {
91
+ private mutationTail: Promise<void> = Promise.resolve();
92
+
93
+ public async find(launchDigestArg: string): Promise<TWorkerCallerBindingRecord | undefined> {
94
+ const stored = await WorkerCallerBindingModel.exact.findStoredOne({ launchDigest: launchDigestArg });
95
+ return stored ? record(WorkerCallerBindingModel.exact.toPersisted(stored)) : undefined;
96
+ }
97
+
98
+ public save(recordArg: TWorkerCallerBindingRecord): Promise<void> {
99
+ return this.mutate(async () => {
100
+ const candidate: IWorkerCallerBindingDocument = { ...structuredClone(recordArg), updateId: plugins.crypto.randomUUID() };
101
+ const stored = await WorkerCallerBindingModel.exact.findStoredOne({ id: candidate.id });
102
+ try {
103
+ if (stored) {
104
+ const current = WorkerCallerBindingModel.exact.toPersisted(stored);
105
+ if (current.launchDigest === candidate.launchDigest) {
106
+ // A launch's binding only gains or renews its job; it is never unbound again.
107
+ if (current.binding && !candidate.binding) throw new Error('A kept worker caller binding cannot lose its job.');
108
+ const result = await WorkerCallerBindingModel.exact.transition({ current: stored, change: (model) => { Object.assign(model, candidate); } });
109
+ if (result.status !== 'transitioned') throw new Error('The kept worker caller binding changed concurrently.');
110
+ return;
111
+ }
112
+ const deleted = await WorkerCallerBindingModel.exact.delete({ current: stored });
113
+ if (deleted.status !== 'deleted') throw new Error('The kept worker caller binding changed concurrently.');
114
+ }
115
+ const inserted = await WorkerCallerBindingModel.exact.insert(candidate);
116
+ if (WorkerCallerBindingModel.exact.toPersisted(inserted.document).updateId !== candidate.updateId) {
117
+ throw new Error('Another worker caller binding holds this launch.');
118
+ }
119
+ } catch (error) {
120
+ if (!ambiguous(error)) throw error;
121
+ const after = await WorkerCallerBindingModel.exact.findStoredOne({ id: candidate.id });
122
+ if (!after || WorkerCallerBindingModel.exact.toPersisted(after).updateId !== candidate.updateId) {
123
+ throw new Error('Keeping the worker caller binding has an uncertain outcome.', { cause: error });
124
+ }
125
+ }
126
+ });
127
+ }
128
+
129
+ public delete(workerIdArg: string, launchDigestArg?: string): Promise<void> {
130
+ return this.mutate(async () => {
131
+ const stored = await WorkerCallerBindingModel.exact.findStoredOne({ id: workerIdArg });
132
+ if (!stored || launchDigestArg !== undefined && WorkerCallerBindingModel.exact.toPersisted(stored).launchDigest !== launchDigestArg) return;
133
+ try {
134
+ const result = await WorkerCallerBindingModel.exact.delete({ current: stored });
135
+ if (result.status !== 'deleted') throw new Error('The kept worker caller binding changed concurrently.');
136
+ } catch (error) {
137
+ if (!ambiguous(error) || await WorkerCallerBindingModel.exact.findStoredOne({ id: workerIdArg })) throw error;
138
+ }
139
+ });
140
+ }
141
+
142
+ private mutate(operationArg: () => Promise<void>): Promise<void> {
143
+ const task = this.mutationTail.then(operationArg);
144
+ this.mutationTail = task.then(() => undefined, () => undefined);
145
+ return task;
146
+ }
147
+ }