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.
- package/changelog.md +33 -0
- package/dist_serve/bundle.js +3514 -3357
- package/dist_serve/bundle.js.map +2 -2
- package/dist_serve/bundle.js.third-party-notices.json +58 -32
- package/dist_serve/bundle.js.third-party-notices.txt +108 -33
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/aglapi/aglapisurface.document.js +64 -37
- package/dist_ts/classes.controller.d.ts +8 -0
- package/dist_ts/classes.controller.js +109 -21
- package/dist_ts/classes.controllereventdelivery.d.ts +72 -0
- package/dist_ts/classes.controllereventdelivery.js +233 -0
- package/dist_ts/classes.flexmodels.public.js +4 -1
- package/dist_ts/classes.flexprojectionreader.d.ts +6 -0
- package/dist_ts/classes.flexprojectionreader.js +29 -5
- package/dist_ts/classes.flexservice.d.ts +2 -1
- package/dist_ts/classes.flexservice.js +34 -6
- package/dist_ts/classes.flexstore.d.ts +41 -4
- package/dist_ts/classes.flexstore.js +266 -203
- package/dist_ts/classes.ticketnotifier.js +4 -2
- package/dist_ts/classes.ticketstore.d.ts +50 -2
- package/dist_ts/classes.ticketstore.js +195 -78
- package/dist_ts/driver/classes.drivereventrelay.d.ts +13 -1
- package/dist_ts/driver/classes.drivereventrelay.js +99 -6
- package/dist_ts/driver/functions.driverinstructions.d.ts +1 -1
- package/dist_ts/driver/functions.driverinstructions.js +1 -1
- package/dist_ts/driver/functions.drivertools.d.ts +11 -2
- package/dist_ts/driver/functions.drivertools.js +19 -9
- package/dist_ts/fleet/classes.boardpump.d.ts +8 -2
- package/dist_ts/fleet/classes.boardpump.js +38 -20
- package/dist_ts/fleet/classes.ticketeffects.d.ts +17 -2
- package/dist_ts/fleet/classes.ticketeffects.js +16 -10
- package/dist_ts/fleet/classes.workers.d.ts +4 -0
- package/dist_ts/fleet/classes.workers.js +37 -17
- package/dist_ts/fleet/functions.workervalidation.js +2 -2
- package/dist_ts/functions.flexprivatesnapshot.d.ts +102 -0
- package/dist_ts/functions.flexprivatesnapshot.js +461 -0
- package/dist_ts/functions.mcptickettools.d.ts +3 -0
- package/dist_ts/functions.mcptickettools.js +2 -2
- package/dist_ts/functions.ticketcli.d.ts +1 -1
- package/dist_ts/functions.ticketcli.js +8 -4
- package/dist_ts/functions.ticketvalidation.d.ts +3 -0
- package/dist_ts/functions.ticketvalidation.js +7 -4
- package/dist_ts/interfaces.flexipc.d.ts +21 -2
- package/dist_ts/interfaces.flexipc.js +33 -16
- package/dist_ts_interfaces/fleetworkers.d.ts +6 -0
- package/dist_ts_interfaces/interfaces.d.ts +1 -1
- package/dist_ts_interfaces/interfaces.js +1 -1
- package/dist_ts_interfaces/requests.tickets.d.ts +8 -12
- package/dist_ts_interfaces/tickets.d.ts +37 -9
- package/dist_ts_interfaces/tickets.js +7 -1
- package/package.json +7 -7
- package/readme.md +54 -10
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/aglapi/aglapisurface.document.ts +63 -36
- package/ts/classes.controller.ts +131 -31
- package/ts/classes.controllereventdelivery.ts +289 -0
- package/ts/classes.flexmodels.public.ts +10 -0
- package/ts/classes.flexprojectionreader.ts +30 -3
- package/ts/classes.flexservice.ts +33 -5
- package/ts/classes.flexstore.ts +381 -246
- package/ts/classes.ticketnotifier.ts +3 -1
- package/ts/classes.ticketstore.ts +201 -75
- package/ts/driver/classes.drivereventrelay.ts +89 -4
- package/ts/driver/functions.driverinstructions.ts +1 -1
- package/ts/driver/functions.drivertools.ts +22 -8
- package/ts/fleet/classes.boardpump.ts +51 -19
- package/ts/fleet/classes.ticketeffects.ts +25 -11
- package/ts/fleet/classes.workers.ts +28 -16
- package/ts/fleet/functions.workervalidation.ts +1 -1
- package/ts/functions.flexprivatesnapshot.ts +601 -0
- package/ts/functions.mcptickettools.ts +1 -1
- package/ts/functions.ticketcli.ts +7 -3
- package/ts/functions.ticketvalidation.ts +7 -3
- package/ts/interfaces.flexipc.ts +50 -21
- package/ts_interfaces/fleetworkers.ts +6 -0
- package/ts_interfaces/interfaces.ts +1 -1
- package/ts_interfaces/requests.tickets.ts +9 -6
- package/ts_interfaces/tickets.ts +32 -1
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/elements.harnesscontrollerapp.ts +19 -8
- package/ts_web/elements.ticketboard.ts +16 -4
- 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
|
-
|
|
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 =
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
226
|
-
const response = await this.perform(methodArg, request, actor, transactionArg,
|
|
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
|
|
256
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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,
|
|
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
|
|
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:
|
|
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,
|
|
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.
|
|
551
|
+
return { ticket: await this.shown(ticket, actorArg, sessionArg, trackArg) };
|
|
440
552
|
}
|
|
441
553
|
const ticketId = (requestArg as { ticketId: string }).ticketId;
|
|
442
|
-
const current =
|
|
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
|
-
|
|
447
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
527
|
-
|
|
528
|
-
|
|
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.
|
|
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.
|
|
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 =
|
|
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 ? [] :
|
|
548
|
-
.filter((recordArg) => recordArg.
|
|
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(
|
|
557
|
-
record.id === ticketId ? 'moved' : 'reordered', nowArg, trackArg, revisionArg)
|
|
681
|
+
changed.push(await this.save(record, next, actorArg, sessionArg,
|
|
682
|
+
record.id === ticketId ? 'moved' : 'reordered', nowArg, trackArg, revisionArg));
|
|
558
683
|
}
|
|
559
684
|
}
|
|
560
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
148
|
+
const text = driverRelayDigest(eventArg.text, maximumEventTextBytes);
|
|
65
149
|
lines.push(...text.split('\n').map((lineArg) => ` > ${lineArg}`));
|
|
66
|
-
if (
|
|
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
|
|
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
|
`;
|