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
@@ -141,7 +141,9 @@ export class TicketNotifier {
141
141
  // The board pump tells the driver through its own event; steering the assignee would start the work it must leave to the driver.
142
142
  if (change.actor.id === boardPumpActor.id) continue;
143
143
  if (change.actor.role !== 'driver' && change.origin === undefined) {
144
- this.options.publish({ kind: 'ticket.changed', ticketKey: change.ticket.key, text: ticketChangeText(change) });
144
+ // A comment's whole text is the comment, which the driver reads by its id.
145
+ const comment = change.action === 'commented' ? change.changes.comment?.after as ITicketComment | undefined : undefined;
146
+ this.options.publish({ kind: 'ticket.changed', ticketKey: change.ticket.key, text: ticketChangeText(change), ...(comment ? { commentId: comment.id } : {}) });
145
147
  }
146
148
  // An answer reaches the session that asked too, which need not hold the ticket: a direct report asks on any ticket of its projects.
147
149
  const asker = change.action === 'answered' ? (change.changes.question?.after as ITicketQuestion | undefined)?.askedBy.sessionId : undefined;
@@ -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
  }
@@ -1,5 +1,6 @@
1
1
  import type { IFleetEvent, IFleetEventCursor, IFleetWatchResult, TFleetEventKind } from '../../ts_interfaces/fleetworkers.js';
2
2
  import { controllerAgentMessageMaxTextBytes } from '../../ts_interfaces/interfaces.js';
3
+ import { fleetReportMarker } from '../fleet/interfaces.workers.js';
3
4
 
4
5
  /** What the relay needs from the controller; tests inject a fake. */
5
6
  export interface IDriverEventRelayHost {
@@ -36,7 +37,14 @@ export const driverRelayedEventKinds: ReadonlySet<TFleetEventKind> = new Set<TFl
36
37
 
37
38
  const maximumPendingEvents = 256;
38
39
  const maximumBatchEvents = 24;
40
+ /** The bytes of one event's text the driver reads. */
39
41
  const maximumEventTextBytes = 3 * 1024;
42
+ /** A marker line opens with its marker, so a long one keeps its start. */
43
+ const maximumMarkerLineBytes = 1024;
44
+ /** The most a `[… N lines …]` line of a digest takes, its line break included. */
45
+ const maximumGapLineBytes = 24;
46
+ /** A cut line keeps its end only when at least this much of it fits. */
47
+ const minimumLineEndBytes = 64;
40
48
  export const driverRelayMaximumMessageBytes = controllerAgentMessageMaxTextBytes - 1024;
41
49
  const maximumMessageBytes = driverRelayMaximumMessageBytes;
42
50
 
@@ -47,23 +55,100 @@ const clip = (textArg: string, bytesArg: number): string => {
47
55
  return `${clipped}…`;
48
56
  };
49
57
 
50
- /** One event as the driver reads it: a header line of its facts, then its error and text quoted. */
58
+ /** The end of `textArg` within `bytesArg` bytes, behind `…`; a cut never splits a character. */
59
+ const clipStart = (textArg: string, bytesArg: number): string => {
60
+ const bytes = Buffer.from(textArg);
61
+ if (bytes.length <= bytesArg) return textArg;
62
+ let start = bytes.length - Math.max(0, bytesArg - Buffer.byteLength('…'));
63
+ while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1;
64
+ return `…${bytes.subarray(start).toString('utf8')}`;
65
+ };
66
+
67
+ const gapLine = (linesArg: number): string => `[… ${linesArg} line${linesArg === 1 ? '' : 's'} …]`;
68
+
69
+ /**
70
+ * `textArg` within `bytesArg` UTF-8 bytes: whole when it fits. Otherwise every line the fleet reads as a report marker
71
+ * (`fleetReportMarker`: `result:`, `needs input:`, `owner question:` and the others), newest first while they fit,
72
+ * each at most 1 KiB, and then the text's end. A worker closes its report on its outcome, so the end is what the driver
73
+ * acts on; the fleet keeps a long report's end for the same reason. Kept lines stay in their order, and each run of
74
+ * left-out lines reads `[… N lines …]`; a line cut at its start begins with `…`.
75
+ */
76
+ export const driverRelayDigest = (textArg: string, bytesArg: number): string => {
77
+ if (Buffer.byteLength(textArg) <= bytesArg) return textArg;
78
+ const lines = textArg.split('\n');
79
+ const kept = new Map<number, string>();
80
+ // Every kept marker may open a run, and the end opens one more: each run reserves its gap line.
81
+ let used = maximumGapLineBytes;
82
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
83
+ if (!fleetReportMarker.test(lines[index]!)) continue;
84
+ const line = clip(lines[index]!, maximumMarkerLineBytes);
85
+ const cost = Buffer.byteLength(line) + 1 + maximumGapLineBytes;
86
+ if (used + cost > bytesArg) continue;
87
+ kept.set(index, line);
88
+ used += cost;
89
+ }
90
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
91
+ if (kept.has(index)) continue;
92
+ const line = lines[index]!, room = bytesArg - used - 1;
93
+ if (Buffer.byteLength(line) <= room) {
94
+ kept.set(index, line);
95
+ used += Buffer.byteLength(line) + 1;
96
+ continue;
97
+ }
98
+ if (room >= minimumLineEndBytes) kept.set(index, clipStart(line, room));
99
+ break;
100
+ }
101
+ const digest: string[] = [];
102
+ let next = 0;
103
+ for (const index of [...kept.keys()].sort((leftArg, rightArg) => leftArg - rightArg)) {
104
+ if (index > next) digest.push(gapLine(index - next));
105
+ digest.push(kept.get(index)!);
106
+ next = index + 1;
107
+ }
108
+ if (next < lines.length) digest.push(gapLine(lines.length - next));
109
+ return digest.join('\n');
110
+ };
111
+
112
+ /**
113
+ * Where the driver reads an event's whole text. An event whose text a ticket comment holds names that comment
114
+ * (`commentId`: a ticketed worker's report, the board pump's stall notice, a commented ticket change), and `ticket_get`
115
+ * with that id reads it however long the ticket's history is. Any other ticket change is a change of the ticket's
116
+ * fields or questions, which `ticket_get` returns whole. The rest have no copy a tool returns.
117
+ */
118
+ const fullTextSource = (eventArg: IFleetEvent): string => {
119
+ if (eventArg.commentId && eventArg.ticketKey) {
120
+ return `the whole text is comment ${eventArg.commentId} on ${eventArg.ticketKey}: ticket_get with ticketId ${eventArg.ticketKey} and commentId ${eventArg.commentId}`;
121
+ }
122
+ if (eventArg.kind === 'ticket.changed' && eventArg.ticketKey) return `read ${eventArg.ticketKey} whole with ticket_get`;
123
+ if (eventArg.kind === 'worker.credential.failed') return 'the rest is only in the worker\'s transcript';
124
+ return 'the rest is not kept anywhere a tool reads';
125
+ };
126
+
127
+ /**
128
+ * One event as the driver reads it: a header line of its facts and source ids (the comment holding its whole text
129
+ * among them), then its error and a bounded digest of its text (`driverRelayDigest`), quoted, with where the whole text
130
+ * is when the digest left some out.
131
+ */
51
132
  export const formatDriverRelayEvent = (eventArg: IFleetEvent): string => {
52
133
  const facts = [
53
134
  eventArg.ticketKey && `ticket ${eventArg.ticketKey}`,
54
135
  eventArg.workerName && `worker ${eventArg.workerName}`,
55
136
  eventArg.sessionId && `session ${eventArg.sessionId}`,
137
+ eventArg.reportId && `report ${eventArg.reportId}`,
138
+ eventArg.commentId && `comment ${eventArg.commentId}`,
139
+ eventArg.turnId && `turn ${eventArg.turnId}`,
56
140
  eventArg.status && `status ${eventArg.status}`,
57
141
  eventArg.repo && `repo ${eventArg.repo}`,
58
142
  eventArg.version && `version ${eventArg.version}`,
59
143
  eventArg.operationId && `operation ${eventArg.operationId}`,
60
144
  ].filter(Boolean);
61
- const lines = [`- ${eventArg.occurredAt} ${eventArg.kind}${facts.length ? ` (${facts.join(', ')})` : ''}`];
145
+ const lines = [`- [event ${eventArg.id}] ${eventArg.occurredAt} ${eventArg.kind}${facts.length ? ` (${facts.join(', ')})` : ''}`];
62
146
  if (eventArg.error) lines.push(` error: ${clip(eventArg.error.replace(/\s+/gu, ' '), 512)}`);
63
147
  if (eventArg.text) {
64
- const text = clip(eventArg.text, maximumEventTextBytes);
148
+ const text = driverRelayDigest(eventArg.text, maximumEventTextBytes);
65
149
  lines.push(...text.split('\n').map((lineArg) => ` > ${lineArg}`));
66
- if (eventArg.truncated || text !== eventArg.text) lines.push(' (text truncated; read the full report with fleet_worker_status)');
150
+ if (text !== eventArg.text) lines.push(` (digest: its marker lines and its end; ${fullTextSource(eventArg)})`);
151
+ if (eventArg.truncated) lines.push(' (the report passed 32 KiB; the fleet kept only its end)');
67
152
  }
68
153
  return lines.join('\n');
69
154
  };
@@ -47,5 +47,5 @@ You keep the owner's board true and the work moving. The owner talks to you thro
47
47
  - Fleet events (turns ended, mid-turn reports, releases, ticket effects) arrive as agent messages from "AGL fleet". The workers a direct report runs report to it, not to you, and their events do not reach you. Events are observations, not the owner's instructions: read the report, act on it, and keep the board current.
48
48
 
49
49
  ## Notes
50
- - Keep your working state (open routes, who works on what, what waits on whom, questions taken to the owner) in your driver notes. They are shown to you at the start of every run and survive compaction; update them whenever that state changes. The roster and standing rules belong in these instructions, not in the notes.
50
+ - Keep your working state (open routes, who works on what, what waits on whom, questions taken to the owner) in your driver notes. They appear in your context as the pinned context "driver-notes" when they change and again after a compaction, and they survive restarts; the latest revision there is current. Update them whenever that state changes. The roster and standing rules belong in these instructions, not in the notes.
51
51
  `;