agl 40.6.0 → 41.0.1

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 (90) hide show
  1. package/changelog.md +40 -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 +110 -22
  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.mcpreexec.js +4 -4
  20. package/dist_ts/classes.ticketnotifier.js +4 -2
  21. package/dist_ts/classes.ticketstore.d.ts +50 -2
  22. package/dist_ts/classes.ticketstore.js +195 -78
  23. package/dist_ts/classes.workercallers.d.ts +2 -0
  24. package/dist_ts/classes.workercallers.js +11 -8
  25. package/dist_ts/driver/classes.drivereventrelay.d.ts +13 -1
  26. package/dist_ts/driver/classes.drivereventrelay.js +99 -6
  27. package/dist_ts/driver/functions.driverinstructions.d.ts +1 -1
  28. package/dist_ts/driver/functions.driverinstructions.js +1 -1
  29. package/dist_ts/driver/functions.drivertools.d.ts +11 -2
  30. package/dist_ts/driver/functions.drivertools.js +19 -9
  31. package/dist_ts/fleet/classes.boardpump.d.ts +8 -2
  32. package/dist_ts/fleet/classes.boardpump.js +38 -20
  33. package/dist_ts/fleet/classes.ticketeffects.d.ts +17 -2
  34. package/dist_ts/fleet/classes.ticketeffects.js +16 -10
  35. package/dist_ts/fleet/classes.workers.d.ts +4 -0
  36. package/dist_ts/fleet/classes.workers.js +37 -17
  37. package/dist_ts/fleet/functions.workervalidation.js +2 -2
  38. package/dist_ts/functions.flexprivatesnapshot.d.ts +102 -0
  39. package/dist_ts/functions.flexprivatesnapshot.js +461 -0
  40. package/dist_ts/functions.mcptickettools.d.ts +3 -0
  41. package/dist_ts/functions.mcptickettools.js +2 -2
  42. package/dist_ts/functions.ticketcli.d.ts +1 -1
  43. package/dist_ts/functions.ticketcli.js +8 -4
  44. package/dist_ts/functions.ticketvalidation.d.ts +3 -0
  45. package/dist_ts/functions.ticketvalidation.js +7 -4
  46. package/dist_ts/functions.workerprocessproof.d.ts +12 -3
  47. package/dist_ts/functions.workerprocessproof.js +17 -6
  48. package/dist_ts/interfaces.flexipc.d.ts +21 -2
  49. package/dist_ts/interfaces.flexipc.js +33 -16
  50. package/dist_ts_interfaces/fleetworkers.d.ts +6 -0
  51. package/dist_ts_interfaces/interfaces.d.ts +1 -1
  52. package/dist_ts_interfaces/interfaces.js +1 -1
  53. package/dist_ts_interfaces/requests.tickets.d.ts +8 -12
  54. package/dist_ts_interfaces/tickets.d.ts +37 -9
  55. package/dist_ts_interfaces/tickets.js +7 -1
  56. package/package.json +7 -7
  57. package/readme.md +62 -15
  58. package/ts/00_commitinfo_data.ts +1 -1
  59. package/ts/aglapi/aglapisurface.document.ts +63 -36
  60. package/ts/classes.controller.ts +132 -32
  61. package/ts/classes.controllereventdelivery.ts +289 -0
  62. package/ts/classes.flexmodels.public.ts +10 -0
  63. package/ts/classes.flexprojectionreader.ts +30 -3
  64. package/ts/classes.flexservice.ts +33 -5
  65. package/ts/classes.flexstore.ts +381 -246
  66. package/ts/classes.mcpreexec.ts +3 -3
  67. package/ts/classes.ticketnotifier.ts +3 -1
  68. package/ts/classes.ticketstore.ts +201 -75
  69. package/ts/classes.workercallers.ts +12 -7
  70. package/ts/driver/classes.drivereventrelay.ts +89 -4
  71. package/ts/driver/functions.driverinstructions.ts +1 -1
  72. package/ts/driver/functions.drivertools.ts +22 -8
  73. package/ts/fleet/classes.boardpump.ts +51 -19
  74. package/ts/fleet/classes.ticketeffects.ts +25 -11
  75. package/ts/fleet/classes.workers.ts +28 -16
  76. package/ts/fleet/functions.workervalidation.ts +1 -1
  77. package/ts/functions.flexprivatesnapshot.ts +601 -0
  78. package/ts/functions.mcptickettools.ts +1 -1
  79. package/ts/functions.ticketcli.ts +7 -3
  80. package/ts/functions.ticketvalidation.ts +7 -3
  81. package/ts/functions.workerprocessproof.ts +17 -3
  82. package/ts/interfaces.flexipc.ts +50 -21
  83. package/ts_interfaces/fleetworkers.ts +6 -0
  84. package/ts_interfaces/interfaces.ts +1 -1
  85. package/ts_interfaces/requests.tickets.ts +9 -6
  86. package/ts_interfaces/tickets.ts +32 -1
  87. package/ts_web/00_commitinfo_data.ts +1 -1
  88. package/ts_web/elements.harnesscontrollerapp.ts +19 -8
  89. package/ts_web/elements.ticketboard.ts +16 -4
  90. package/ts_web/settings.general.ts +10 -4
@@ -0,0 +1,601 @@
1
+ import * as plugins from './plugins.js';
2
+ import {
3
+ type IFlexPrivateCandidateDocument,
4
+ type IFlexPrivateChunkDocument,
5
+ type TFlexDomainKind,
6
+ assertFlexPrivateCandidateDocument,
7
+ assertFlexPrivateChunkDocument,
8
+ flexPrivateChunkBytes,
9
+ } from './classes.flexmodels.private.js';
10
+
11
+ /*
12
+ * A private snapshot is stored as the JSON of the snapshot cut into content-addressed chunks
13
+ * (`IFlexPrivateChunkDocument`), listed in order by a candidate (`IFlexPrivateCandidateDocument`)
14
+ * that names the SHA-256 of the whole JSON. The JSON is cut in runs, and a chunk never spans two
15
+ * runs:
16
+ *
17
+ * - a growing list of a projection (`packedLists`) is its own run, packed entry by entry from the
18
+ * list's start into chunks of up to 64 KiB (an entry over the chunk size is cut in slices), so a
19
+ * chunk's end depends only on the entries before it;
20
+ * - everything else, and the whole snapshot of the other domains, is cut into slices of
21
+ * `flexPrivateChunkBytes` from the run's start.
22
+ *
23
+ * A save rebuilds only what its change touches: it keeps the layout of the candidate it replaces
24
+ * (`IFlexPrivateLayout`: each chunk with its bytes and where each list entry lies in them), takes
25
+ * every chunk whose bytes the change leaves in place, and serializes, hashes and encodes only the
26
+ * entries and chunks from the change on. The whole-snapshot SHA-256 is one pass over the chunks'
27
+ * bytes in order. A build without a previous layout is the full path: it serializes every entry.
28
+ * Both produce the bytes AGL 40.5.0 wrote; `test/test.flexprivatesnapshot.node.ts` proves it
29
+ * against that serializer.
30
+ */
31
+
32
+ /** The lists of a domain snapshot that grow entry by entry, each packed in its own run. */
33
+ const packedLists: Partial<Record<TFlexDomainKind, readonly string[]>> = {
34
+ projection: ['messages', 'reversionSegments'],
35
+ };
36
+
37
+ /**
38
+ * The list a domain snapshot stores as the last member of its one run. Its entries' bytes stay
39
+ * where they are when the snapshot's other members keep their length, so a save that changes the
40
+ * list's newest entries rebuilds the run's first slice and the slices from the change on.
41
+ */
42
+ const streamedLists: Partial<Record<TFlexDomainKind, string>> = {
43
+ agentEvents: 'events',
44
+ };
45
+
46
+ /** A chunk of a growing list stays under this size, so a change rewrites little of the list. */
47
+ const privateListChunkBytes = 64 * 1024;
48
+
49
+ const sha256 = (valueArg: Uint8Array | string): string =>
50
+ plugins.crypto.createHash('sha256').update(valueArg).digest('hex');
51
+
52
+ /** The domain incarnation whose snapshot a layout stores: its chunks are addressed within it. */
53
+ export interface IFlexPrivateSnapshotOwner {
54
+ controllerId: string;
55
+ storageKey: string;
56
+ domain: TFlexDomainKind;
57
+ sessionId?: string;
58
+ archiveId?: string;
59
+ incarnationId: string;
60
+ }
61
+
62
+ export const privateCandidateDocumentId = (candidateIdArg: string): string =>
63
+ `flex-private-candidate:${candidateIdArg}`;
64
+
65
+ /**
66
+ * Private chunks are addressed by their content within one domain incarnation, so a save stores
67
+ * only the chunks it changed and shares the others with the snapshot it replaces.
68
+ */
69
+ export const privateChunkDocumentId = (
70
+ ownerArg: IFlexPrivateSnapshotOwner,
71
+ chunkDigestArg: string,
72
+ ): string => `flex-private-chunk:${sha256(JSON.stringify([
73
+ ownerArg.controllerId,
74
+ ownerArg.storageKey,
75
+ ownerArg.domain,
76
+ ownerArg.sessionId ?? null,
77
+ ownerArg.archiveId ?? null,
78
+ ownerArg.incarnationId,
79
+ chunkDigestArg,
80
+ ]))}`;
81
+
82
+ const ownerKey = (ownerArg: IFlexPrivateSnapshotOwner): string => JSON.stringify([
83
+ ownerArg.controllerId,
84
+ ownerArg.storageKey,
85
+ ownerArg.domain,
86
+ ownerArg.sessionId ?? null,
87
+ ownerArg.archiveId ?? null,
88
+ ownerArg.incarnationId,
89
+ ]);
90
+
91
+ /** A stored chunk and its bytes, which stay valid while a cached layout holds the chunk. */
92
+ export interface IFlexPrivateChunk {
93
+ readonly id: string;
94
+ readonly sha256: string;
95
+ readonly bytes: number;
96
+ /** The chunk's own memory, zeroed once no cached layout holds it. */
97
+ readonly data: Buffer;
98
+ /** How many cached layouts hold the chunk. */
99
+ holders: number;
100
+ }
101
+
102
+ /** The list a sliced run streams between its head and tail, and where each entry's piece ends. */
103
+ interface IFlexSlicedList {
104
+ readonly key: string;
105
+ readonly entries: readonly unknown[];
106
+ /** The offset in the run where each entry's piece (its JSON, after a comma but the first) ends. */
107
+ readonly ends: readonly number[];
108
+ }
109
+
110
+ /** A run cut into slices of `flexPrivateChunkBytes` from its start. */
111
+ interface IFlexSlicedRun {
112
+ readonly kind: 'sliced';
113
+ readonly head: Buffer;
114
+ readonly list?: IFlexSlicedList;
115
+ readonly tail: Buffer;
116
+ readonly bytes: number;
117
+ readonly chunks: readonly IFlexPrivateChunk[];
118
+ }
119
+
120
+ /** A chunk of a packed run: whole pieces `first` to `end`, or one slice of the piece `first`. */
121
+ interface IFlexPackedChunk {
122
+ readonly chunk: IFlexPrivateChunk;
123
+ readonly first: number;
124
+ readonly end: number;
125
+ readonly sliced: boolean;
126
+ }
127
+
128
+ /** A list's run of entry pieces, packed into chunks from the list's start. */
129
+ interface IFlexPackedRun {
130
+ readonly kind: 'packed';
131
+ readonly key: string;
132
+ readonly entries: readonly unknown[];
133
+ /** The bytes of each entry's piece, the separating comma included. */
134
+ readonly pieceBytes: readonly number[];
135
+ readonly chunks: readonly IFlexPackedChunk[];
136
+ }
137
+
138
+ type TFlexPrivateRun = IFlexSlicedRun | IFlexPackedRun;
139
+
140
+ /** How a stored private snapshot lies in its chunks, kept while this writer holds its lease. */
141
+ export interface IFlexPrivateLayout {
142
+ readonly owner: string;
143
+ readonly runs: readonly TFlexPrivateRun[];
144
+ /** Every chunk, in candidate order. */
145
+ readonly chunks: readonly IFlexPrivateChunk[];
146
+ readonly bytes: number;
147
+ readonly sha256: string;
148
+ }
149
+
150
+ type TFlexRunSource =
151
+ | {
152
+ kind: 'sliced';
153
+ head: string;
154
+ list?: { key: string; entries: readonly unknown[] };
155
+ tail: string;
156
+ }
157
+ | { kind: 'packed'; key: string; entries: readonly unknown[] };
158
+
159
+ /** A snapshot that streams `keyArg` when it is the last member and a list; else its JSON whole. */
160
+ const streamedRun = (snapshotArg: object, keyArg: string | undefined): TFlexRunSource => {
161
+ const keys = Object.keys(snapshotArg);
162
+ const value = keyArg === undefined ? undefined : Reflect.get(snapshotArg, keyArg);
163
+ if (keyArg === undefined || keys.at(-1) !== keyArg || !Array.isArray(value)) {
164
+ return { kind: 'sliced', head: JSON.stringify(snapshotArg), tail: '' };
165
+ }
166
+ const rest = JSON.stringify(Object.fromEntries(
167
+ keys.slice(0, -1).map((key) => [key, Reflect.get(snapshotArg, key)]),
168
+ ));
169
+ return {
170
+ kind: 'sliced',
171
+ head: `${rest.slice(0, -1)}${rest === '{}' ? '' : ','}${JSON.stringify(keyArg)}:[`,
172
+ list: { key: keyArg, entries: value },
173
+ tail: ']}',
174
+ };
175
+ };
176
+
177
+ /** The runs of a snapshot's JSON, in order. */
178
+ const describeRuns = (snapshotArg: unknown, domainArg: TFlexDomainKind): TFlexRunSource[] => {
179
+ if (snapshotArg === null || typeof snapshotArg !== 'object' || Array.isArray(snapshotArg)) {
180
+ return [{ kind: 'sliced', head: JSON.stringify(snapshotArg), tail: '' }];
181
+ }
182
+ const listKeys = packedLists[domainArg] ?? [];
183
+ if (listKeys.length === 0) return [streamedRun(snapshotArg, streamedLists[domainArg])];
184
+ const runs: TFlexRunSource[] = [];
185
+ let frame = '{';
186
+ let first = true;
187
+ for (const [key, value] of Object.entries(snapshotArg)) {
188
+ if (value === undefined) continue;
189
+ frame += `${first ? '' : ','}${JSON.stringify(key)}:`;
190
+ first = false;
191
+ if (listKeys.includes(key) && Array.isArray(value) && value.length > 0) {
192
+ runs.push({ kind: 'sliced', head: `${frame}[`, tail: '' });
193
+ runs.push({ kind: 'packed', key, entries: value });
194
+ frame = ']';
195
+ } else {
196
+ frame += JSON.stringify(value);
197
+ }
198
+ }
199
+ runs.push({ kind: 'sliced', head: `${frame}}`, tail: '' });
200
+ return runs;
201
+ };
202
+
203
+ /** How many entries two lists share from their start, by identity. */
204
+ const sharedPrefix = (leftArg: readonly unknown[], rightArg: readonly unknown[]): number => {
205
+ const limit = Math.min(leftArg.length, rightArg.length);
206
+ let index = 0;
207
+ while (index < limit && leftArg[index] === rightArg[index]) index++;
208
+ return index;
209
+ };
210
+
211
+ type TMint = (dataArg: Buffer) => IFlexPrivateChunk;
212
+
213
+ /** A piece of a run's new bytes: its length and how to copy a range of it. */
214
+ interface IFlexRunSegment {
215
+ readonly bytes: number;
216
+ copy(targetArg: Buffer, targetOffsetArg: number, fromArg: number, toArg: number): void;
217
+ }
218
+
219
+ const bufferSegment = (bufferArg: Buffer): IFlexRunSegment => ({
220
+ bytes: bufferArg.byteLength,
221
+ copy: (targetArg, targetOffsetArg, fromArg, toArg) => {
222
+ bufferArg.copy(targetArg, targetOffsetArg, fromArg, toArg);
223
+ },
224
+ });
225
+
226
+ /** The bytes `fromArg` to `toArg` of a sliced run, read from its chunks. */
227
+ const previousSegment = (runArg: IFlexSlicedRun, fromArg: number, toArg: number): IFlexRunSegment => ({
228
+ bytes: toArg - fromArg,
229
+ copy: (targetArg, targetOffsetArg, startArg, endArg) => {
230
+ let offset = fromArg + startArg;
231
+ let target = targetOffsetArg;
232
+ while (offset < fromArg + endArg) {
233
+ const index = Math.floor(offset / flexPrivateChunkBytes);
234
+ const within = offset - index * flexPrivateChunkBytes;
235
+ const length = Math.min(fromArg + endArg - offset, flexPrivateChunkBytes - within);
236
+ runArg.chunks[index]!.data.copy(targetArg, target, within, within + length);
237
+ offset += length;
238
+ target += length;
239
+ }
240
+ },
241
+ });
242
+
243
+ /**
244
+ * Fills the chunks `rangesArg` names (sorted, disjoint, in run offsets) from the run's segments in
245
+ * one pass.
246
+ */
247
+ const fillRanges = (
248
+ segmentsArg: readonly IFlexRunSegment[],
249
+ rangesArg: ReadonlyArray<{ start: number; end: number; data: Buffer }>,
250
+ ): void => {
251
+ let rangeIndex = 0;
252
+ let position = 0;
253
+ for (const segment of segmentsArg) {
254
+ const segmentEnd = position + segment.bytes;
255
+ while (rangeIndex < rangesArg.length && rangesArg[rangeIndex]!.end <= position) rangeIndex++;
256
+ for (let index = rangeIndex; index < rangesArg.length && rangesArg[index]!.start < segmentEnd; index++) {
257
+ const range = rangesArg[index]!;
258
+ const from = Math.max(range.start, position);
259
+ const to = Math.min(range.end, segmentEnd);
260
+ if (from < to) segment.copy(range.data, from - range.start, from - position, to - position);
261
+ }
262
+ position = segmentEnd;
263
+ }
264
+ };
265
+
266
+ /** `textArg` as UTF-8 in memory of its own, never a slice of Node's shared buffer pool. */
267
+ const ownBytes = (textArg: string): Buffer => {
268
+ const data = Buffer.alloc(Buffer.byteLength(textArg, 'utf8'));
269
+ data.write(textArg, 'utf8');
270
+ return data;
271
+ };
272
+
273
+ const updateSlicedRun = (
274
+ previousArg: IFlexSlicedRun | undefined,
275
+ sourceArg: Extract<TFlexRunSource, { kind: 'sliced' }>,
276
+ mintArg: TMint,
277
+ ): IFlexSlicedRun => {
278
+ const head = ownBytes(sourceArg.head);
279
+ const tail = ownBytes(sourceArg.tail);
280
+ const entries = sourceArg.list?.entries ?? [];
281
+ const previousList = previousArg?.list !== undefined && previousArg.list.key === sourceArg.list?.key
282
+ ? previousArg.list
283
+ : undefined;
284
+ const kept = previousList ? sharedPrefix(entries, previousList.entries) : 0;
285
+ // The pieces of the kept entries are taken from the previous run's bytes, never serialized again.
286
+ const previousListStart = previousArg?.head.byteLength ?? 0;
287
+ const keptEnd = kept > 0 ? previousList!.ends[kept - 1]! : previousListStart;
288
+ const fresh: Buffer[] = [];
289
+ for (let index = kept; index < entries.length; index++) {
290
+ fresh.push(Buffer.from(`${index === 0 ? '' : ','}${JSON.stringify(entries[index]) ?? 'null'}`, 'utf8'));
291
+ }
292
+ const shift = head.byteLength - previousListStart;
293
+ const ends: number[] = [];
294
+ for (let index = 0; index < kept; index++) ends.push(previousList!.ends[index]! + shift);
295
+ let offset = head.byteLength + keptEnd - previousListStart;
296
+ for (const piece of fresh) {
297
+ offset += piece.byteLength;
298
+ ends.push(offset);
299
+ }
300
+ const bytes = offset + tail.byteLength;
301
+
302
+ // The bytes from `cleanFrom` to `cleanTo` are the previous run's bytes at the same offsets.
303
+ let cleanFrom = Number.POSITIVE_INFINITY;
304
+ let cleanTo = 0;
305
+ if (previousArg && head.byteLength === previousArg.head.byteLength) {
306
+ cleanFrom = head.equals(previousArg.head) ? 0 : head.byteLength;
307
+ const listUnchanged = previousList
308
+ ? kept === entries.length && kept === previousList.entries.length
309
+ : sourceArg.list === undefined && previousArg.list === undefined;
310
+ cleanTo = listUnchanged && tail.equals(previousArg.tail) ? bytes : keptEnd;
311
+ }
312
+ const count = Math.max(1, Math.ceil(bytes / flexPrivateChunkBytes));
313
+ const chunks: Array<IFlexPrivateChunk | undefined> = [];
314
+ const ranges: Array<{ index: number; start: number; end: number; data: Buffer }> = [];
315
+ for (let index = 0; index < count; index++) {
316
+ const start = index * flexPrivateChunkBytes;
317
+ const end = Math.min(start + flexPrivateChunkBytes, bytes);
318
+ const previousChunk = previousArg?.chunks[index];
319
+ if (
320
+ previousChunk
321
+ && start >= cleanFrom
322
+ && end <= cleanTo
323
+ && Math.min(start + flexPrivateChunkBytes, previousArg!.bytes) === end
324
+ ) {
325
+ chunks.push(previousChunk);
326
+ } else {
327
+ chunks.push(undefined);
328
+ ranges.push({ index, start, end, data: Buffer.alloc(end - start) });
329
+ }
330
+ }
331
+ if (ranges.length > 0) {
332
+ fillRanges([
333
+ bufferSegment(head),
334
+ ...(kept > 0 ? [previousSegment(previousArg!, previousListStart, keptEnd)] : []),
335
+ ...fresh.map(bufferSegment),
336
+ bufferSegment(tail),
337
+ ], ranges);
338
+ for (const range of ranges) chunks[range.index] = mintArg(range.data);
339
+ }
340
+ for (const piece of fresh) piece.fill(0);
341
+ return {
342
+ kind: 'sliced',
343
+ head,
344
+ ...(sourceArg.list ? { list: { key: sourceArg.list.key, entries, ends } } : {}),
345
+ tail,
346
+ bytes,
347
+ chunks: chunks as IFlexPrivateChunk[],
348
+ };
349
+ };
350
+
351
+ /** The index of the last chunk that starts at or before piece `pieceArg`: the chunk holding it. */
352
+ const chunkHolding = (chunksArg: readonly IFlexPackedChunk[], pieceArg: number): number => {
353
+ let low = 0;
354
+ let high = chunksArg.length - 1;
355
+ while (low < high) {
356
+ const middle = (low + high + 1) >>> 1;
357
+ if (chunksArg[middle]!.first <= pieceArg) low = middle;
358
+ else high = middle - 1;
359
+ }
360
+ return low;
361
+ };
362
+
363
+ const copyOf = (partsArg: readonly Buffer[], bytesArg: number): Buffer => {
364
+ const data = Buffer.alloc(bytesArg);
365
+ let offset = 0;
366
+ for (const part of partsArg) offset += part.copy(data, offset);
367
+ return data;
368
+ };
369
+
370
+ const updatePackedRun = (
371
+ previousArg: IFlexPackedRun | undefined,
372
+ sourceArg: Extract<TFlexRunSource, { kind: 'packed' }>,
373
+ mintArg: TMint,
374
+ ): IFlexPackedRun => {
375
+ const entries = sourceArg.entries;
376
+ const kept = previousArg ? sharedPrefix(entries, previousArg.entries) : 0;
377
+ if (previousArg && kept === entries.length && kept === previousArg.entries.length) {
378
+ return { ...previousArg, entries };
379
+ }
380
+ // Chunks end where the packing from the list's start ends them, so every chunk before the one
381
+ // holding the last kept piece stays; packing resumes at that chunk's start, where it began
382
+ // empty. A kept piece cut in slices ends its own chunks, so packing resumes after it.
383
+ const chunks: IFlexPackedChunk[] = [];
384
+ let restart = 0;
385
+ let restartChunk: IFlexPackedChunk | undefined;
386
+ if (previousArg && kept > 0) {
387
+ const holding = chunkHolding(previousArg.chunks, kept - 1);
388
+ const chunk = previousArg.chunks[holding]!;
389
+ if (chunk.sliced) {
390
+ chunks.push(...previousArg.chunks.slice(0, holding + 1));
391
+ restart = kept;
392
+ } else {
393
+ chunks.push(...previousArg.chunks.slice(0, holding));
394
+ restart = chunk.first;
395
+ restartChunk = chunk;
396
+ }
397
+ }
398
+ const pieceBytes = previousArg ? previousArg.pieceBytes.slice(0, kept) : [];
399
+ let pending: Buffer[] = [];
400
+ let pendingBytes = 0;
401
+ let pendingFirst = restart;
402
+ const fresh: Buffer[] = [];
403
+ const flush = (endArg: number): void => {
404
+ if (pending.length === 0) return;
405
+ chunks.push({
406
+ chunk: mintArg(copyOf(pending, pendingBytes)),
407
+ first: pendingFirst,
408
+ end: endArg,
409
+ sliced: false,
410
+ });
411
+ pending = [];
412
+ pendingBytes = 0;
413
+ };
414
+ let keptOffset = 0;
415
+ for (let index = restart; index < entries.length; index++) {
416
+ let piece: Buffer;
417
+ if (index < kept) {
418
+ const length = previousArg!.pieceBytes[index]!;
419
+ piece = restartChunk!.chunk.data.subarray(keptOffset, keptOffset + length);
420
+ keptOffset += length;
421
+ } else {
422
+ piece = Buffer.from(`${index === 0 ? '' : ','}${JSON.stringify(entries[index])}`, 'utf8');
423
+ pieceBytes.push(piece.byteLength);
424
+ fresh.push(piece);
425
+ }
426
+ if (pendingBytes > 0 && pendingBytes + piece.byteLength > privateListChunkBytes) flush(index);
427
+ if (piece.byteLength <= flexPrivateChunkBytes - pendingBytes) {
428
+ if (pending.length === 0) pendingFirst = index;
429
+ pending.push(piece);
430
+ pendingBytes += piece.byteLength;
431
+ continue;
432
+ }
433
+ flush(index);
434
+ for (let offset = 0; offset < piece.byteLength; offset += flexPrivateChunkBytes) {
435
+ chunks.push({
436
+ chunk: mintArg(copyOf(
437
+ [piece.subarray(offset, Math.min(offset + flexPrivateChunkBytes, piece.byteLength))],
438
+ Math.min(flexPrivateChunkBytes, piece.byteLength - offset),
439
+ )),
440
+ first: index,
441
+ end: index + 1,
442
+ sliced: true,
443
+ });
444
+ }
445
+ }
446
+ flush(entries.length);
447
+ for (const piece of fresh) piece.fill(0);
448
+ return { kind: 'packed', key: sourceArg.key, entries, pieceBytes, chunks };
449
+ };
450
+
451
+ export interface IFlexPrivateSnapshotBuildOptions {
452
+ owner: IFlexPrivateSnapshotOwner;
453
+ candidateId: string;
454
+ revision: number;
455
+ createdAt: string;
456
+ snapshot: unknown;
457
+ /**
458
+ * The layout of the candidate the domain head names now. Its unchanged chunks are taken as
459
+ * they are; without it every entry is serialized (the full path).
460
+ */
461
+ previous?: IFlexPrivateLayout;
462
+ /** The ids of the chunks the current candidate stores, which a save never writes again. */
463
+ sharedChunkIds: ReadonlySet<string>;
464
+ }
465
+
466
+ export interface IFlexPrivateSnapshotBuild {
467
+ readonly layout: IFlexPrivateLayout;
468
+ readonly candidate: IFlexPrivateCandidateDocument;
469
+ /** The documents of the chunks this build made that `sharedChunkIds` does not name, in order. */
470
+ readonly chunks: IFlexPrivateChunkDocument[];
471
+ /** The chunks this build made; zero those no cached layout holds once the save ends. */
472
+ readonly minted: readonly IFlexPrivateChunk[];
473
+ }
474
+
475
+ /** The candidate and chunk documents that store `snapshot`, and the layout they leave. */
476
+ export const buildFlexPrivateSnapshot = (
477
+ optionsArg: IFlexPrivateSnapshotBuildOptions,
478
+ ): IFlexPrivateSnapshotBuild => {
479
+ const owner = optionsArg.owner;
480
+ const key = ownerKey(owner);
481
+ const previous = optionsArg.previous?.owner === key ? optionsArg.previous : undefined;
482
+ const minted: IFlexPrivateChunk[] = [];
483
+ const mint: TMint = (dataArg) => {
484
+ const digest = sha256(dataArg);
485
+ const chunk: IFlexPrivateChunk = {
486
+ id: privateChunkDocumentId(owner, digest),
487
+ sha256: digest,
488
+ bytes: dataArg.byteLength,
489
+ data: dataArg,
490
+ holders: 0,
491
+ };
492
+ minted.push(chunk);
493
+ return chunk;
494
+ };
495
+ try {
496
+ const previousPacked = new Map<string, IFlexPackedRun>();
497
+ const previousSliced: IFlexSlicedRun[] = [];
498
+ for (const run of previous?.runs ?? []) {
499
+ if (run.kind === 'packed') previousPacked.set(run.key, run);
500
+ else previousSliced.push(run);
501
+ }
502
+ let slicedIndex = 0;
503
+ const runs = describeRuns(optionsArg.snapshot, owner.domain).map((source): TFlexPrivateRun => (
504
+ source.kind === 'packed'
505
+ ? updatePackedRun(previousPacked.get(source.key), source, mint)
506
+ : updateSlicedRun(previousSliced[slicedIndex++], source, mint)
507
+ ));
508
+ const chunks = runs.flatMap((run) => (
509
+ run.kind === 'packed' ? run.chunks.map((entry) => entry.chunk) : run.chunks
510
+ ));
511
+ if (chunks.length === 0) chunks.push(mint(Buffer.alloc(0)));
512
+ // The whole-snapshot digest: one pass over the chunks' bytes, never joined into one buffer.
513
+ const hash = plugins.crypto.createHash('sha256');
514
+ let bytes = 0;
515
+ for (const chunk of chunks) {
516
+ hash.update(chunk.data);
517
+ bytes += chunk.bytes;
518
+ }
519
+ const digest = hash.digest('hex');
520
+ const identity = {
521
+ domain: owner.domain,
522
+ incarnationId: owner.incarnationId,
523
+ ...(owner.sessionId !== undefined ? { sessionId: owner.sessionId } : {}),
524
+ ...(owner.archiveId !== undefined ? { archiveId: owner.archiveId } : {}),
525
+ };
526
+ const made = new Set(minted);
527
+ const documents: IFlexPrivateChunkDocument[] = [];
528
+ chunks.forEach((chunk, index) => {
529
+ if (!made.has(chunk) || optionsArg.sharedChunkIds.has(chunk.id)) return;
530
+ documents.push({
531
+ id: chunk.id,
532
+ controllerId: owner.controllerId,
533
+ scopeId: owner.storageKey,
534
+ candidateId: optionsArg.candidateId,
535
+ revision: optionsArg.revision,
536
+ index,
537
+ sha256: chunk.sha256,
538
+ bytes: chunk.bytes,
539
+ data: chunk.data.toString('base64'),
540
+ createdAt: optionsArg.createdAt,
541
+ ...identity,
542
+ });
543
+ });
544
+ const candidate: IFlexPrivateCandidateDocument = {
545
+ id: privateCandidateDocumentId(optionsArg.candidateId),
546
+ controllerId: owner.controllerId,
547
+ scopeId: owner.storageKey,
548
+ candidateId: optionsArg.candidateId,
549
+ revision: optionsArg.revision,
550
+ sha256: digest,
551
+ bytes,
552
+ chunks: chunks.map(({ id, sha256: chunkDigest, bytes: chunkBytes }, index) => ({
553
+ id, index, sha256: chunkDigest, bytes: chunkBytes,
554
+ })),
555
+ createdAt: optionsArg.createdAt,
556
+ ...identity,
557
+ };
558
+ assertFlexPrivateCandidateDocument(candidate);
559
+ for (const document of documents) assertFlexPrivateChunkDocument(document);
560
+ return {
561
+ layout: { owner: key, runs, chunks, bytes, sha256: digest },
562
+ candidate,
563
+ chunks: documents,
564
+ minted,
565
+ };
566
+ } catch (errorArg) {
567
+ for (const chunk of minted) chunk.data.fill(0);
568
+ throw errorArg;
569
+ }
570
+ };
571
+
572
+ /** Marks the layout's chunks as held by one more cached snapshot. */
573
+ export const holdFlexPrivateLayout = (layoutArg: IFlexPrivateLayout): void => {
574
+ for (const chunk of new Set(layoutArg.chunks)) chunk.holders += 1;
575
+ };
576
+
577
+ /**
578
+ * Ends one cached snapshot's hold on the layout: zeroes its own run frames and the chunks no
579
+ * layout holds now.
580
+ */
581
+ export const releaseFlexPrivateLayout = (layoutArg: IFlexPrivateLayout): void => {
582
+ for (const chunk of new Set(layoutArg.chunks)) {
583
+ chunk.holders -= 1;
584
+ if (chunk.holders <= 0) {
585
+ chunk.holders = 0;
586
+ chunk.data.fill(0);
587
+ }
588
+ }
589
+ for (const run of layoutArg.runs) {
590
+ if (run.kind !== 'sliced') continue;
591
+ run.head.fill(0);
592
+ run.tail.fill(0);
593
+ }
594
+ };
595
+
596
+ /** Zeroes the chunks no cached layout holds, such as those of a build that was not cached. */
597
+ export const discardFlexPrivateChunks = (chunksArg: readonly IFlexPrivateChunk[]): void => {
598
+ for (const chunk of chunksArg) {
599
+ if (chunk.holders === 0) chunk.data.fill(0);
600
+ }
601
+ };
@@ -4,7 +4,7 @@ import type { TTicketOperation } from '../ts_interfaces/tickets.js';
4
4
  const workflow = ' Caller identity comes from AGL, never a supplied name. Unidentified callers may list/get tickets with empty mine; every mutation requires a verified caller. Workers create in backlog and may update/move only their assigned tickets within ready, in_progress, needs_input and review. Link a branch with a full sha before Review. Only the driver or owner assigns, lands or releases tickets.';
5
5
  export const ticketToolDefinitions = [
6
6
  { name: 'tickets_list', operation: 'list', description: 'Read the shared board across all projects. Filter by mine, project, status, label or text. mine uses your verified session. label also matches AutoLabels, which AGL computes and nobody sets: openQuestion marks a ticket with an unanswered question.' },
7
- { name: 'ticket_get', operation: 'get', description: 'Read a ticket by its AGL-<n> key, with markdown comments, source, links, its questions and their answers, and the complete activity log.' },
7
+ { name: 'ticket_get', operation: 'get', description: 'Read a ticket by its AGL-<n> key: source, links, all its questions and their answers, and the newest page of its history, oldest first: up to limit entries (default 50, at most 500), fewer when the entries and their comments would pass 256 KiB. activity is the page; comments are the markdown comments it names, and a commented entry names its comment by id. historyBefore is present while older history exists: pass it as before for the next older page. commentId instead reads the one comment an activity entry or fleet event names (without before or limit).' },
8
8
  { name: 'ticket_create', operation: 'create', description: 'Capture discussed work as a ticket. Include project, title, description and sourceText (the original words); optional attachments are named URLs. Worker-created tickets start in backlog for driver triage.' },
9
9
  { name: 'ticket_update', operation: 'update', description: 'Update the title, markdown description, priority, labels, project or parent of your assigned ticket. Supply expectedRevision from the ticket you read; stale edits return TICKET_REVISION_CONFLICT with currentRevision. Reload before editing again. Status is changed only with ticket_move; assignment is driver/owner-only.' },
10
10
  { name: 'ticket_move', operation: 'move', description: 'Atomically move/reorder your assigned ticket. Omit beforeTicketId to append. Returns every record whose column or order changed. Review requires a linked branch and sha. Use needs_input to report a blocker.' },
@@ -8,7 +8,10 @@ import type { ITicketChanges, ITicketPerson, TTicketOperation, TTicketRequest, T
8
8
  export const ticketCliHelp = `Usage: agl ticket <list|get|create|update|move|comment|link|assign|ask|answer> [AGL-<n>] [options]
9
9
  list [--mine] [--project ID] [--status COLUMN] [--label LABEL] [--text TEXT]
10
10
  --label also matches AutoLabels, which AGL computes: openQuestion (an unanswered question)
11
- get AGL-<n>
11
+ get AGL-<n> [--limit N] [--before HISTORY-ID] [--comment COMMENT-ID]
12
+ The ticket, its questions and the newest N history entries (default 50, at most 500, within 256 KiB);
13
+ historyBefore names the next older page: pass it as --before.
14
+ --comment reads the one comment an activity entry names instead (not with --limit or --before)
12
15
  create --project ID --title TEXT [--description MARKDOWN] [--kind epic|task|subtask]
13
16
  [--parent AGL-<n>] [--priority none|low|normal|high|urgent] [--labels a,b]
14
17
  [--source TEXT] [--attachments JSON] [--column COLUMN]
@@ -38,7 +41,7 @@ export interface ITicketCliArguments {
38
41
  }
39
42
  const common = ['port', 'json'];
40
43
  const actionOptions: Record<string, string[]> = {
41
- list: ['mine', 'project', 'status', 'label', 'text'], get: [],
44
+ list: ['mine', 'project', 'status', 'label', 'text'], get: ['limit', 'before', 'comment'],
42
45
  create: ['project', 'title', 'description', 'kind', 'parent', 'priority', 'labels', 'source', 'attachments', 'column'],
43
46
  update: ['expected-revision', 'changes', 'title', 'description', 'priority', 'project', 'labels', 'parent', 'clear-parent'],
44
47
  move: ['column', 'before'], comment: ['body'], link: ['repo', 'branch', 'sha', 'package', 'version'],
@@ -78,7 +81,8 @@ export const parseTicketCliArguments = (argumentsArg: string[]): ITicketCliArgum
78
81
  const operation = action === 'assign' ? 'update' : action as TTicketOperation;
79
82
  if (action === 'list') request = { ...(options.has('mine') ? { mine: true } : {}), ...optional('project'),
80
83
  ...optional('status'), ...optional('label'), ...optional('text') };
81
- else if (action === 'get') request = { ticketId };
84
+ else if (action === 'get') request = { ticketId, ...optional('before'), ...optional('comment', 'commentId'),
85
+ ...(options.has('limit') ? { limit: Number(required('limit')) } : {}) };
82
86
  else if (action === 'create') request = { project: required('project'), title: required('title'), ...optional('description'),
83
87
  ...optional('kind'), ...optional('parent', 'parentId'), ...optional('priority'), ...labels(), ...optional('source', 'sourceText'),
84
88
  ...optional('column', 'columnId'), ...(options.has('attachments') ? { attachments: JSON.parse(required('attachments')) } : {}) };
@@ -1,5 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
- import { ticketAutoLabels, ticketColumns, type TTicketOperation, type TTicketRequest } from '../ts_interfaces/tickets.js';
2
+ import { ticketAutoLabels, ticketColumns, ticketHistoryPage, type TTicketOperation, type TTicketRequest } from '../ts_interfaces/tickets.js';
3
3
 
4
4
  const text = (maximumArg: number) => plugins.z.string().max(maximumArg);
5
5
  const identifier = text(1024).min(1).refine((valueArg) => !/[\x00-\x1f\x7f]/u.test(valueArg));
@@ -38,7 +38,11 @@ const changes = plugins.z.object({
38
38
  export const ticketInputSchemas = {
39
39
  list: filters, board: filters,
40
40
  search: plugins.z.object({ query: text(1024), label: text(100).min(1).optional() }).strict(),
41
- get: plugins.z.object({ ticketId }).strict(), comments: plugins.z.object({ ticketId }).strict(),
41
+ get: plugins.z.object({ ticketId, before: identifier.optional(),
42
+ limit: plugins.z.number().int().min(1).max(ticketHistoryPage.maximum).optional(), commentId: identifier.optional() }).strict()
43
+ .refine((valueArg) => valueArg.commentId === undefined || (valueArg.before === undefined && valueArg.limit === undefined),
44
+ 'commentId reads one comment and excludes before and limit.'),
45
+ comments: plugins.z.object({ ticketId }).strict(),
42
46
  create: plugins.z.object({ project: identifier, kind: kind.optional(), title: text(200).trim().min(1),
43
47
  description: text(64 * 1024).optional(), columnId: plugins.z.enum(ticketColumns).optional(), parentId: ticketId.optional(),
44
48
  priority: priority.optional(), assignee: person.optional(), labels: labels.optional(), sourceText: text(64 * 1024).optional(),
@@ -117,7 +121,7 @@ export const assertTicketResponse = (methodArg: string, valueArg: unknown): void
117
121
  id: plugins.z.enum(ticketColumns), title: plugins.z.string(), done: plugins.z.boolean().optional() }).strict()), tickets: plugins.z.array(ticket),
118
122
  viewer: person }).strict(),
119
123
  get: plugins.z.object({ ticket, comments: plugins.z.array(ticketCommentSchema), activity: plugins.z.array(ticketActivitySchema),
120
- questions: plugins.z.array(ticketQuestionSchema) }).strict(),
124
+ questions: plugins.z.array(ticketQuestionSchema), historyBefore: identifier.optional() }).strict(),
121
125
  comments: plugins.z.object({ comments: plugins.z.array(ticketCommentSchema) }).strict(),
122
126
  create: plugins.z.object({ ticket }).strict(), update: plugins.z.object({ ticket }).strict(),
123
127
  link: plugins.z.object({ ticket }).strict(), move: plugins.z.object({ tickets: plugins.z.array(ticket) }).strict(),