agl 40.5.1 → 41.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +43 -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.aglhome.d.ts +5 -0
- package/dist_ts/classes.aglhome.js +2 -1
- package/dist_ts/classes.authstore.js +5 -1
- package/dist_ts/classes.controller.d.ts +8 -0
- package/dist_ts/classes.controller.js +119 -22
- 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.inferencegateway.d.ts +35 -1
- package/dist_ts/classes.inferencegateway.js +86 -26
- package/dist_ts/classes.inferenceservice.d.ts +4 -2
- package/dist_ts/classes.inferenceservice.js +5 -4
- package/dist_ts/classes.proxycontrol.d.ts +58 -4
- package/dist_ts/classes.proxycontrol.js +23 -44
- package/dist_ts/classes.proxycontrolclient.d.ts +15 -5
- package/dist_ts/classes.proxycontrolclient.js +67 -17
- package/dist_ts/classes.proxydaemon.d.ts +22 -3
- package/dist_ts/classes.proxydaemon.js +73 -12
- package/dist_ts/classes.proxyleaseholder.d.ts +43 -14
- package/dist_ts/classes.proxyleaseholder.js +66 -22
- package/dist_ts/classes.proxystore.d.ts +9 -0
- package/dist_ts/classes.proxystore.js +15 -7
- 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/classes.workercallerbindings.d.ts +66 -0
- package/dist_ts/classes.workercallerbindings.js +211 -0
- package/dist_ts/classes.workercallers.d.ts +63 -2
- package/dist_ts/classes.workercallers.js +430 -65
- 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.workerlaunches.d.ts +12 -5
- package/dist_ts/fleet/classes.workerlaunches.js +25 -19
- package/dist_ts/fleet/classes.workers.d.ts +21 -0
- package/dist_ts/fleet/classes.workers.js +110 -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.inferencecli.d.ts +3 -2
- package/dist_ts/functions.inferencecli.js +67 -5
- package/dist_ts/functions.inferencecliarguments.d.ts +6 -2
- package/dist_ts/functions.inferencecliarguments.js +29 -2
- package/dist_ts/functions.inferencelaunch.d.ts +24 -0
- package/dist_ts/functions.inferencelaunch.js +42 -2
- package/dist_ts/functions.mcptickettools.d.ts +3 -0
- package/dist_ts/functions.mcptickettools.js +2 -2
- package/dist_ts/functions.proxycontract.d.ts +11 -0
- package/dist_ts/functions.proxycontract.js +12 -1
- package/dist_ts/functions.proxysocket.d.ts +6 -0
- package/dist_ts/functions.proxysocket.js +46 -0
- 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/functions.workerprocessproof.d.ts +6 -0
- package/dist_ts/functions.workerprocessproof.js +28 -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/dist_ts_interfaces/workercallers.d.ts +43 -4
- package/package.json +7 -7
- package/readme.md +156 -23
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/aglapi/aglapisurface.document.ts +63 -36
- package/ts/classes.aglhome.ts +6 -0
- package/ts/classes.authstore.ts +4 -0
- package/ts/classes.controller.ts +141 -32
- 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.inferencegateway.ts +104 -23
- package/ts/classes.inferenceservice.ts +6 -3
- package/ts/classes.proxycontrol.ts +76 -39
- package/ts/classes.proxycontrolclient.ts +72 -20
- package/ts/classes.proxydaemon.ts +85 -15
- package/ts/classes.proxyleaseholder.ts +84 -28
- package/ts/classes.proxystore.ts +16 -5
- package/ts/classes.ticketnotifier.ts +3 -1
- package/ts/classes.ticketstore.ts +201 -75
- package/ts/classes.workercallerbindings.ts +147 -0
- package/ts/classes.workercallers.ts +410 -59
- 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.workerlaunches.ts +23 -15
- package/ts/fleet/classes.workers.ts +79 -17
- package/ts/fleet/functions.workervalidation.ts +1 -1
- package/ts/functions.flexprivatesnapshot.ts +601 -0
- package/ts/functions.inferencecli.ts +61 -7
- package/ts/functions.inferencecliarguments.ts +32 -1
- package/ts/functions.inferencelaunch.ts +49 -2
- package/ts/functions.mcptickettools.ts +1 -1
- package/ts/functions.proxycontract.ts +12 -0
- package/ts/functions.proxysocket.ts +39 -0
- package/ts/functions.ticketcli.ts +7 -3
- package/ts/functions.ticketvalidation.ts +7 -3
- package/ts/functions.workerprocessproof.ts +27 -4
- 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_interfaces/workercallers.ts +32 -4
- 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
|
@@ -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
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type { IWorkerBridgeCommand, IWorkerProcessSnapshot } from './functions.workerprocessproof.js';
|
|
3
|
+
|
|
4
|
+
/** The Claude process a kept binding was last proved against: its PID, start and the digest of its whole snapshot. */
|
|
5
|
+
export interface IWorkerCallerBindingProcess {
|
|
6
|
+
pid: number;
|
|
7
|
+
startTicks: string;
|
|
8
|
+
snapshotDigest: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the controller keeps of a fleet Claude worker's current caller launch, so a restarted controller proves the
|
|
13
|
+
* worker's bridge again instead of forgetting it. Never a credential, never the launch id or generation themselves:
|
|
14
|
+
* the overlay holds those, and a bridge presents them; the record keeps their digests to find and check the launch.
|
|
15
|
+
*/
|
|
16
|
+
export interface IWorkerCallerBindingDocument {
|
|
17
|
+
/** The fleet worker's id: a worker keeps one binding, its current launch's. */
|
|
18
|
+
id: string;
|
|
19
|
+
projectId: string;
|
|
20
|
+
harnessId: 'claude';
|
|
21
|
+
/** SHA-256 digests (base64url) of the launch id and of its generation. */
|
|
22
|
+
launchDigest: string;
|
|
23
|
+
generationDigest: string;
|
|
24
|
+
/** The bridge command approved when the launch was prepared, and the Claude Code executable it was proved against. */
|
|
25
|
+
command: IWorkerBridgeCommand;
|
|
26
|
+
claudeExecutable: string;
|
|
27
|
+
revision: number;
|
|
28
|
+
/** Once bound: the background job, its session, and the job's process when one was proved. */
|
|
29
|
+
binding?: { nativeSessionId: string; jobId: string; process?: IWorkerCallerBindingProcess };
|
|
30
|
+
updateId: string;
|
|
31
|
+
}
|
|
32
|
+
export type TWorkerCallerBindingRecord = Omit<IWorkerCallerBindingDocument, 'updateId'>;
|
|
33
|
+
|
|
34
|
+
/** Where the controller keeps the bindings of fleet Claude launches. */
|
|
35
|
+
export interface IWorkerCallerBindingStore {
|
|
36
|
+
/** The binding kept for the launch whose id has the digest `launchDigestArg`. */
|
|
37
|
+
find(launchDigestArg: string): Promise<TWorkerCallerBindingRecord | undefined>;
|
|
38
|
+
/** Keeps `recordArg` as its worker's binding; another launch's binding of the worker is replaced. */
|
|
39
|
+
save(recordArg: TWorkerCallerBindingRecord): Promise<void>;
|
|
40
|
+
/** Deletes the worker's binding; with `launchDigestArg`, only while it is that launch's. */
|
|
41
|
+
delete(workerIdArg: string, launchDigestArg?: string): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const workerCallerDigest = (valueArg: string): string => plugins.crypto.createHash('sha256').update(valueArg).digest('base64url');
|
|
45
|
+
export const workerProcessSnapshotDigest = (snapshotArg: IWorkerProcessSnapshot): string => workerCallerDigest(JSON.stringify([
|
|
46
|
+
snapshotArg.pid, snapshotArg.parentPid, snapshotArg.startTicks, snapshotArg.executable, snapshotArg.arguments]));
|
|
47
|
+
|
|
48
|
+
const digest = plugins.z.string().regex(/^[A-Za-z0-9_-]{43}$/u);
|
|
49
|
+
const absolutePath = plugins.z.string().min(2).max(4096).refine((valueArg) => plugins.path.isAbsolute(valueArg) && !valueArg.includes('\0'));
|
|
50
|
+
const documentSchema = plugins.z.object({
|
|
51
|
+
id: plugins.z.string().min(3).max(1024), projectId: plugins.z.string().regex(/^[A-Za-z0-9_.:-]{1,256}$/u),
|
|
52
|
+
harnessId: plugins.z.literal('claude'), launchDigest: digest, generationDigest: digest,
|
|
53
|
+
command: plugins.z.object({ executable: absolutePath, entry: absolutePath,
|
|
54
|
+
arguments: plugins.z.array(plugins.z.string().max(4096)).max(64) }).strict(),
|
|
55
|
+
claudeExecutable: absolutePath, revision: plugins.z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
56
|
+
binding: plugins.z.object({ nativeSessionId: plugins.z.string().uuid(), jobId: plugins.z.string().regex(/^[A-Za-z0-9_.:-]{1,256}$/u),
|
|
57
|
+
process: plugins.z.object({ pid: plugins.z.number().int().min(2), startTicks: plugins.z.string().regex(/^\d{1,32}$/u),
|
|
58
|
+
snapshotDigest: digest }).strict().optional() }).strict().optional(),
|
|
59
|
+
updateId: plugins.z.string().uuid(),
|
|
60
|
+
}).strict();
|
|
61
|
+
|
|
62
|
+
@plugins.nosqldb.namedIndex({ name: 'worker_caller_launch', key: { launchDigest: 1 }, options: { unique: true } })
|
|
63
|
+
@plugins.nosqldb.managed({ collectionName: 'agl_worker_caller_bindings' })
|
|
64
|
+
@plugins.nosqldb.exactPersistence({ assertDocument: (input: unknown) => {
|
|
65
|
+
const doc = documentSchema.parse(input);
|
|
66
|
+
if (!doc.id.startsWith(`${doc.projectId}:`)) throw new Error('Worker caller binding identity mismatch.');
|
|
67
|
+
if (doc.binding && doc.revision < 1) throw new Error('Worker caller binding revision mismatch.');
|
|
68
|
+
} })
|
|
69
|
+
export class WorkerCallerBindingModel extends plugins.nosqldb.SmartDataDbDoc<WorkerCallerBindingModel, IWorkerCallerBindingDocument> {
|
|
70
|
+
declare static exact: plugins.nosqldb.TExact<WorkerCallerBindingModel>;
|
|
71
|
+
@plugins.nosqldb.unI() public id!: string;
|
|
72
|
+
@plugins.nosqldb.svDb() public projectId!: string;
|
|
73
|
+
@plugins.nosqldb.svDb() public harnessId!: 'claude';
|
|
74
|
+
@plugins.nosqldb.svDb() public launchDigest!: string;
|
|
75
|
+
@plugins.nosqldb.svDb() public generationDigest!: string;
|
|
76
|
+
@plugins.nosqldb.svDb() public command!: IWorkerBridgeCommand;
|
|
77
|
+
@plugins.nosqldb.svDb() public claudeExecutable!: string;
|
|
78
|
+
@plugins.nosqldb.svDb() public revision!: number;
|
|
79
|
+
@plugins.nosqldb.svDb() public binding?: IWorkerCallerBindingDocument['binding'];
|
|
80
|
+
@plugins.nosqldb.svDb() public updateId!: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ambiguous = (errorArg: unknown): boolean => errorArg instanceof plugins.nosqldb.SmartdataExactPersistenceError && errorArg.code === 'ambiguous_write';
|
|
84
|
+
const record = (documentArg: IWorkerCallerBindingDocument): TWorkerCallerBindingRecord => {
|
|
85
|
+
const { updateId: _updateId, ...rest } = structuredClone(documentArg);
|
|
86
|
+
return rest;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** The kept bindings in the controller's store; the auth store owns the model manager and database. Writes run one at a time. */
|
|
90
|
+
export class WorkerCallerBindingStore implements IWorkerCallerBindingStore {
|
|
91
|
+
private mutationTail: Promise<void> = Promise.resolve();
|
|
92
|
+
|
|
93
|
+
public async find(launchDigestArg: string): Promise<TWorkerCallerBindingRecord | undefined> {
|
|
94
|
+
const stored = await WorkerCallerBindingModel.exact.findStoredOne({ launchDigest: launchDigestArg });
|
|
95
|
+
return stored ? record(WorkerCallerBindingModel.exact.toPersisted(stored)) : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public save(recordArg: TWorkerCallerBindingRecord): Promise<void> {
|
|
99
|
+
return this.mutate(async () => {
|
|
100
|
+
const candidate: IWorkerCallerBindingDocument = { ...structuredClone(recordArg), updateId: plugins.crypto.randomUUID() };
|
|
101
|
+
const stored = await WorkerCallerBindingModel.exact.findStoredOne({ id: candidate.id });
|
|
102
|
+
try {
|
|
103
|
+
if (stored) {
|
|
104
|
+
const current = WorkerCallerBindingModel.exact.toPersisted(stored);
|
|
105
|
+
if (current.launchDigest === candidate.launchDigest) {
|
|
106
|
+
// A launch's binding only gains or renews its job; it is never unbound again.
|
|
107
|
+
if (current.binding && !candidate.binding) throw new Error('A kept worker caller binding cannot lose its job.');
|
|
108
|
+
const result = await WorkerCallerBindingModel.exact.transition({ current: stored, change: (model) => { Object.assign(model, candidate); } });
|
|
109
|
+
if (result.status !== 'transitioned') throw new Error('The kept worker caller binding changed concurrently.');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const deleted = await WorkerCallerBindingModel.exact.delete({ current: stored });
|
|
113
|
+
if (deleted.status !== 'deleted') throw new Error('The kept worker caller binding changed concurrently.');
|
|
114
|
+
}
|
|
115
|
+
const inserted = await WorkerCallerBindingModel.exact.insert(candidate);
|
|
116
|
+
if (WorkerCallerBindingModel.exact.toPersisted(inserted.document).updateId !== candidate.updateId) {
|
|
117
|
+
throw new Error('Another worker caller binding holds this launch.');
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (!ambiguous(error)) throw error;
|
|
121
|
+
const after = await WorkerCallerBindingModel.exact.findStoredOne({ id: candidate.id });
|
|
122
|
+
if (!after || WorkerCallerBindingModel.exact.toPersisted(after).updateId !== candidate.updateId) {
|
|
123
|
+
throw new Error('Keeping the worker caller binding has an uncertain outcome.', { cause: error });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
public delete(workerIdArg: string, launchDigestArg?: string): Promise<void> {
|
|
130
|
+
return this.mutate(async () => {
|
|
131
|
+
const stored = await WorkerCallerBindingModel.exact.findStoredOne({ id: workerIdArg });
|
|
132
|
+
if (!stored || launchDigestArg !== undefined && WorkerCallerBindingModel.exact.toPersisted(stored).launchDigest !== launchDigestArg) return;
|
|
133
|
+
try {
|
|
134
|
+
const result = await WorkerCallerBindingModel.exact.delete({ current: stored });
|
|
135
|
+
if (result.status !== 'deleted') throw new Error('The kept worker caller binding changed concurrently.');
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (!ambiguous(error) || await WorkerCallerBindingModel.exact.findStoredOne({ id: workerIdArg })) throw error;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private mutate(operationArg: () => Promise<void>): Promise<void> {
|
|
143
|
+
const task = this.mutationTail.then(operationArg);
|
|
144
|
+
this.mutationTail = task.then(() => undefined, () => undefined);
|
|
145
|
+
return task;
|
|
146
|
+
}
|
|
147
|
+
}
|