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
|
@@ -125,7 +125,7 @@ export const driverToolDefinitions: readonly IDriverToolDefinition[] = [
|
|
|
125
125
|
...directReportToolDefinitions,
|
|
126
126
|
{
|
|
127
127
|
name: 'driver_notes_read',
|
|
128
|
-
description: 'Read your driver notes and their revision.
|
|
128
|
+
description: 'Read your driver notes and their revision. Your context also shows them as the pinned context "driver-notes" whenever they changed and after a compaction; its latest revision is current.',
|
|
129
129
|
inputSchema: plugins.z.object({}).strict(),
|
|
130
130
|
readOnly: true,
|
|
131
131
|
route: { kind: 'notes', operation: 'read' },
|
|
@@ -147,10 +147,24 @@ export const driverToolsByName: ReadonlyMap<string, IDriverToolDefinition> = new
|
|
|
147
147
|
driverToolDefinitions.map((definitionArg) => [definitionArg.name, definitionArg]),
|
|
148
148
|
);
|
|
149
149
|
|
|
150
|
-
/** The
|
|
151
|
-
export const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
150
|
+
/** The id of the pinned context that carries the driver's notes. */
|
|
151
|
+
export const driverNotesContextId = 'driver-notes';
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The driver's notes as a FlexHarness pinned context, outside its system prompt: the model sees a
|
|
155
|
+
* revision once, when it is new and again after a compaction, and a new revision leaves the system
|
|
156
|
+
* prompt, the tools and the history in front of it as they were, so the cached prefix survives it.
|
|
157
|
+
* `revisionArg` is the revision of the session scratchpad that holds the notes, which every saved
|
|
158
|
+
* text moves; never the FlexHarness project revision. Empty notes are pinned too, so a cleared
|
|
159
|
+
* text replaces the one saved before it.
|
|
160
|
+
*/
|
|
161
|
+
export const driverNotesContext = (
|
|
162
|
+
notesArg: string,
|
|
163
|
+
revisionArg: number,
|
|
164
|
+
): plugins.flexharness.IFlexPinnedContext => ({
|
|
165
|
+
id: driverNotesContextId,
|
|
166
|
+
revision: revisionArg,
|
|
167
|
+
text: notesArg.trim()
|
|
168
|
+
? `Your driver notes, as you last saved them with driver_notes_update:\n\n${notesArg.trim()}`
|
|
169
|
+
: 'Your driver notes are empty. Save your working state with driver_notes_update.',
|
|
170
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
|
-
import type { ITicket, ITicketActivity, ITicketActor, ITicketComment, ITicketPerson, ITicketQuestion, TTicketColumn
|
|
2
|
+
import type { ITicket, ITicketActivity, ITicketActor, ITicketComment, ITicketPerson, ITicketQuestion, TTicketColumn } from '../../ts_interfaces/tickets.js';
|
|
3
|
+
import type { ITicketHistory } from '../classes.ticketstore.js';
|
|
3
4
|
import type { IFleetBoardPumpConfiguration, IFleetUnboundDriver } from '../../ts_interfaces/fleet.js';
|
|
4
5
|
import type { IFleetEvent } from '../../ts_interfaces/fleetworkers.js';
|
|
5
6
|
import type { IControllerFleetWorkerView } from '../../ts_interfaces/index.js';
|
|
@@ -17,7 +18,8 @@ export interface IBoardPumpHost {
|
|
|
17
18
|
/** The fleet owner the pump acts for and its configuration; undefined while the configuration authority is unavailable. */
|
|
18
19
|
scope(): Promise<{ owner: string; configuration?: IFleetBoardPumpConfiguration } | undefined>;
|
|
19
20
|
tickets(): Promise<ITicket[]>;
|
|
20
|
-
ticket
|
|
21
|
+
/** One ticket with its whole history and questions; the pump asks only when the ticket's revision moved. */
|
|
22
|
+
history(ticketKeyArg: string): Promise<ITicketHistory>;
|
|
21
23
|
workers(ownerArg: string): Promise<IControllerFleetWorkerView[]>;
|
|
22
24
|
/** Steers the live worker bound to the native session `harness:nativeId`; rejects when the harness did not admit the text. */
|
|
23
25
|
steer(sessionIdArg: string, ticketKeyArg: string, textArg: string): Promise<unknown>;
|
|
@@ -44,6 +46,18 @@ interface IBoardPumpStall {
|
|
|
44
46
|
namesUnboundDrivers?: true;
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
/**
|
|
50
|
+
* What the pump decides on from one ticket revision: when the ticket entered its column, its questions, and the comment
|
|
51
|
+
* nobody followed. Every change to a ticket, its history or its questions moves its revision, so these hold while it does.
|
|
52
|
+
*/
|
|
53
|
+
interface IBoardPumpTicketFacts {
|
|
54
|
+
ticket: ITicket;
|
|
55
|
+
entered: { id: string; at: string };
|
|
56
|
+
questions: ITicketQuestion[];
|
|
57
|
+
/** The newest discussion entry when it is a comment by someone not in the driver role that nothing by the driver role or the assignee followed. */
|
|
58
|
+
unfollowed?: { actor: ITicketActor; comment: ITicketComment };
|
|
59
|
+
}
|
|
60
|
+
|
|
47
61
|
const watchedColumns = new Set<TTicketColumn>(['ready', 'in_progress', 'needs_input', 'landed']);
|
|
48
62
|
/** The activity a ticket's discussion consists of: its comments and its questions. */
|
|
49
63
|
const discussionActions = new Set<ITicketActivity['action']>(['commented', 'asked', 'answered']);
|
|
@@ -74,6 +88,16 @@ const clip = (textArg: string, maximumArg = 32 * 1024): string => {
|
|
|
74
88
|
const characters = [...textArg];
|
|
75
89
|
return characters.length > maximumArg ? `${characters.slice(0, maximumArg - 1).join('').trimEnd()}…` : textArg;
|
|
76
90
|
};
|
|
91
|
+
/** The facts of one history; see `IBoardPumpTicketFacts`. */
|
|
92
|
+
const ticketFacts = ({ ticket, activity, questions }: ITicketHistory): IBoardPumpTicketFacts => {
|
|
93
|
+
const own = (entryArg: ITicketActivity) => entryArg.actor.id === boardPumpActor.id;
|
|
94
|
+
const index = activity.findLastIndex((entryArg) => discussionActions.has(entryArg.action) && !own(entryArg));
|
|
95
|
+
const entry = activity[index];
|
|
96
|
+
const comment = entry && entry.action === 'commented' && entry.actor.role !== 'driver' ? entry.changes.comment?.after as ITicketComment | undefined : undefined;
|
|
97
|
+
const followed = activity.slice(index + 1).some((laterArg) => !own(laterArg) && (laterArg.actor.role === 'driver' || isPerson(laterArg.actor, ticket.assignee)));
|
|
98
|
+
return { ticket, entered: enteredColumn(ticket, activity), questions,
|
|
99
|
+
...(entry && comment && !followed ? { unfollowed: { actor: entry.actor, comment } } : {}) };
|
|
100
|
+
};
|
|
77
101
|
const answerText = (questionArg: ITicketQuestion): string => {
|
|
78
102
|
const answer = questionArg.answer!;
|
|
79
103
|
const chosen = answer.choices.map((choiceArg) => questionArg.options.find((optionArg) => optionArg.id === choiceArg)?.label ?? choiceArg);
|
|
@@ -100,6 +124,8 @@ export class BoardPump {
|
|
|
100
124
|
private running?: Promise<void>;
|
|
101
125
|
/** The configuration changed during a pass, so another pass follows at once with it. */
|
|
102
126
|
private changed = false;
|
|
127
|
+
/** The facts of each ticket at the revision they were read at; a ticket gone from the board is forgotten. */
|
|
128
|
+
private readonly facts = new Map<string, IBoardPumpTicketFacts>();
|
|
103
129
|
|
|
104
130
|
constructor(private readonly host: IBoardPumpHost, options: IBoardPumpOptions = {}) {
|
|
105
131
|
this.now = options.now ?? Date.now;
|
|
@@ -140,6 +166,8 @@ export class BoardPump {
|
|
|
140
166
|
const now = this.now();
|
|
141
167
|
const tickets = (await this.host.tickets()).filter((ticketArg) => ticketArg.columnId !== 'released');
|
|
142
168
|
const workers = tickets.length ? await this.host.workers(scope.owner) : [];
|
|
169
|
+
const listed = new Set(tickets.map((ticketArg) => ticketArg.key));
|
|
170
|
+
for (const key of this.facts.keys()) if (!listed.has(key)) this.facts.delete(key);
|
|
143
171
|
const seen = new Set<string>();
|
|
144
172
|
for (const ticket of tickets) {
|
|
145
173
|
this.abort.signal.throwIfAborted();
|
|
@@ -174,16 +202,25 @@ export class BoardPump {
|
|
|
174
202
|
const watched = watchedColumns.has(column) && !(column === 'in_progress' && (ticketArg.labels.includes(boardPumpWaitingLabel) || live.length > 0));
|
|
175
203
|
const comments = column !== 'released' && !live.length;
|
|
176
204
|
if (!watched && !comments) return [];
|
|
177
|
-
const
|
|
178
|
-
if (
|
|
205
|
+
const facts = await this.factsOf(ticketArg);
|
|
206
|
+
if (facts.ticket.columnId !== column) return [];
|
|
179
207
|
const stalls: IBoardPumpStall[] = [];
|
|
180
|
-
const stall = watched ? this.columnStall(
|
|
208
|
+
const stall = watched ? this.columnStall(facts, bound, live, landedAfterHoursArg, nowArg) : undefined;
|
|
181
209
|
if (stall) stalls.push(stall);
|
|
182
|
-
const comment = comments ? this.commentStall(
|
|
210
|
+
const comment = comments ? this.commentStall(facts, bound) : undefined;
|
|
183
211
|
if (comment) stalls.push(comment);
|
|
184
212
|
return stalls;
|
|
185
213
|
}
|
|
186
214
|
|
|
215
|
+
/** The ticket's facts, read again only when its revision is not the one they were read at. */
|
|
216
|
+
private async factsOf(ticketArg: ITicket): Promise<IBoardPumpTicketFacts> {
|
|
217
|
+
const known = this.facts.get(ticketArg.key);
|
|
218
|
+
if (known && known.ticket.revision === ticketArg.revision) return known;
|
|
219
|
+
const facts = ticketFacts(await this.host.history(ticketArg.key));
|
|
220
|
+
this.facts.set(ticketArg.key, facts);
|
|
221
|
+
return facts;
|
|
222
|
+
}
|
|
223
|
+
|
|
187
224
|
private header(ticketArg: ITicket): string {
|
|
188
225
|
return `${ticketArg.key} ${quoted(ticketArg.title)} (${ticketArg.project}${ticketArg.assignee ? `, assigned to ${quoted(ticketArg.assignee.name)}` : ', unassigned'})`;
|
|
189
226
|
}
|
|
@@ -193,18 +230,13 @@ export class BoardPump {
|
|
|
193
230
|
* by someone not in the driver role, nothing by the driver role or the assignee followed it, and no live fleet worker
|
|
194
231
|
* holds the ticket. Its notice names the Claude Code jobs that look like drivers and that no worker binds.
|
|
195
232
|
*/
|
|
196
|
-
private commentStall({ ticket,
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
const entry = activity[index];
|
|
200
|
-
if (!entry || entry.action !== 'commented' || entry.actor.role === 'driver') return undefined;
|
|
201
|
-
const comment = entry.changes.comment?.after as ITicketComment | undefined;
|
|
202
|
-
if (!comment) return undefined;
|
|
203
|
-
if (activity.slice(index + 1).some((laterArg) => !own(laterArg) && (laterArg.actor.role === 'driver' || isPerson(laterArg.actor, ticket.assignee)))) return undefined;
|
|
233
|
+
private commentStall({ ticket, unfollowed }: IBoardPumpTicketFacts, boundArg: IControllerFleetWorkerView[]): IBoardPumpStall | undefined {
|
|
234
|
+
if (!unfollowed) return undefined;
|
|
235
|
+
const { actor, comment } = unfollowed;
|
|
204
236
|
const text = comment.body.map((partArg) => partArg.type === 'text' ? partArg.text : `@${partArg.label}`).join('').trim();
|
|
205
237
|
const last = boundArg.at(-1);
|
|
206
238
|
return { ticket, column: ticket.columnId, key: `board-pump:${ticket.key}:comment:${comment.id}`, namesUnboundDrivers: true,
|
|
207
|
-
notice: `${this.header(ticket)} has a comment by ${quoted(
|
|
239
|
+
notice: `${this.header(ticket)} has a comment by ${quoted(actor.name)} (${actor.role}) from ${comment.createdAt} that nothing `
|
|
208
240
|
+ `by the driver role or the assignee followed, and no live fleet worker holds the ticket, so no session was told:\n\n`
|
|
209
241
|
+ ` ${clip(text, 2000).replaceAll(/\r?\n/gu, '\n ')}\n\n`
|
|
210
242
|
+ (last ? `Its last worker is ${workerText(last)}; resume it with fleet_worker_steer (name ${quoted(last.worker.name)}, ticketKey ${ticket.key}). `
|
|
@@ -212,11 +244,10 @@ export class BoardPump {
|
|
|
212
244
|
+ 'Claude Code background jobs that look like drivers and that no fleet worker binds:' };
|
|
213
245
|
}
|
|
214
246
|
|
|
215
|
-
private columnStall({ ticket,
|
|
247
|
+
private columnStall({ ticket, entered, questions }: IBoardPumpTicketFacts, bound: IControllerFleetWorkerView[], live: IControllerFleetWorkerView[],
|
|
216
248
|
landedAfterHoursArg: number, nowArg: number): IBoardPumpStall | undefined {
|
|
217
249
|
const column = ticket.columnId;
|
|
218
250
|
const assigned = ticket.assignee?.sessionId;
|
|
219
|
-
const entered = enteredColumn(ticket, activity);
|
|
220
251
|
const header = this.header(ticket);
|
|
221
252
|
const key = (stateArg: unknown[]) => `board-pump:${ticket.key}:${column}:${plugins.crypto.createHash('sha256')
|
|
222
253
|
.update(JSON.stringify([entered.id, ...stateArg])).digest('hex').slice(0, 32)}`;
|
|
@@ -292,10 +323,11 @@ export class BoardPump {
|
|
|
292
323
|
private async apply(ownerArg: string, docArg: IFleetTicketEffectDocument, kindArg: 'ticket.notified' | 'ticket.stalled',
|
|
293
324
|
stallArg: IBoardPumpStall): Promise<boolean> {
|
|
294
325
|
if (docArg.state === 'applied') return true;
|
|
295
|
-
const effect = await this.host.effects.dispatch(docArg, this.abort.signal);
|
|
326
|
+
const { effect, commentId } = await this.host.effects.dispatch(docArg, this.abort.signal);
|
|
296
327
|
if (effect.state !== 'applied') return false;
|
|
297
328
|
const text = docArg.request.kind === 'comment' && typeof docArg.request.body === 'string' ? docArg.request.body : stallArg.notice;
|
|
298
|
-
|
|
329
|
+
// The notice is its comment, so the event names it: the driver reads it whole with ticket_get and commentId.
|
|
330
|
+
this.host.publish({ kind: kindArg, ticketKey: stallArg.ticket.key, status: stallArg.column, text, ...(commentId ? { commentId } : {}),
|
|
299
331
|
...(kindArg === 'ticket.notified' && stallArg.delivery ? { sessionId: stallArg.delivery.sessionId, workerName: stallArg.delivery.worker.worker.name } : {}) },
|
|
300
332
|
ownerArg);
|
|
301
333
|
return true;
|
|
@@ -32,6 +32,14 @@ export class FleetTicketEffectModel extends plugins.nosqldb.SmartDataDbDoc<Fleet
|
|
|
32
32
|
@plugins.nosqldb.svDb() public origin?: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* A dispatched effect and, when the board wrote or replayed a comment for it, that comment's id. The id is a return
|
|
37
|
+
* value only: neither the effect record nor `IFleetTicketSync` stores it.
|
|
38
|
+
*/
|
|
39
|
+
export interface IFleetTicketDispatch { effect: IFleetTicketEffect; commentId?: string }
|
|
40
|
+
/** A report's ticket sync and the id of the comment that holds the report, when the board wrote it. */
|
|
41
|
+
export interface IFleetTicketReport { sync: IFleetTicketSync; commentId?: string }
|
|
42
|
+
|
|
35
43
|
/** Owns exact requests; the ticket board owns effects and atomic idempotency receipts. */
|
|
36
44
|
export class FleetTicketEffects {
|
|
37
45
|
constructor(private readonly apply: (event: TTicketFleetEvent, signal?: AbortSignal, origin?: string) => Promise<ITicketFleetEventResult>) {}
|
|
@@ -93,16 +101,20 @@ export class FleetTicketEffects {
|
|
|
93
101
|
if (observed && (observed.updateId === updateId || observed.state === 'applied')) return this.publicEffect(FleetTicketEffectModel.exact.toPersisted(observed));
|
|
94
102
|
throw new Error('Fleet ticket confirmation outcome is unresolved; reconcile its saved request/key.');
|
|
95
103
|
}
|
|
96
|
-
|
|
104
|
+
/** Applies the saved request; a comment the board returned (a replay returns the stored one) is named by its id. */
|
|
105
|
+
public async dispatch(doc: IFleetTicketEffectDocument, signal?: AbortSignal): Promise<IFleetTicketDispatch> {
|
|
106
|
+
let commentId: string | undefined;
|
|
107
|
+
const named = (effect: IFleetTicketEffect): IFleetTicketDispatch => ({ effect, ...(commentId ? { commentId } : {}) });
|
|
97
108
|
try {
|
|
98
109
|
// Even confirmed replays use the durable board receipt, never a local dedupe cache.
|
|
99
110
|
const result = await this.apply(structuredClone(doc.request), signal, doc.origin);
|
|
100
|
-
|
|
111
|
+
commentId = result.comment?.id;
|
|
112
|
+
return named(await this.mark(doc, 'applied', result.revision));
|
|
101
113
|
} catch (error) {
|
|
102
114
|
const state = error instanceof TicketError && error.code !== 'outcome_unknown' ? 'failed' : 'unknown';
|
|
103
115
|
const message = error instanceof Error ? error.message.slice(0, 4096) : 'Fleet ticket application outcome is unknown.';
|
|
104
|
-
try { return await this.mark(doc, state, undefined, message); }
|
|
105
|
-
catch { return { eventKey: doc.id, ticketKey: doc.request.ticketKey, state: 'unknown', error: 'Fleet ticket confirmation was not saved; reconcile the exact event key.' }; }
|
|
116
|
+
try { return named(await this.mark(doc, state, undefined, message)); }
|
|
117
|
+
catch { return named({ eventKey: doc.id, ticketKey: doc.request.ticketKey, state: 'unknown', error: 'Fleet ticket confirmation was not saved; reconcile the exact event key.' }); }
|
|
106
118
|
}
|
|
107
119
|
}
|
|
108
120
|
public summary(effects: IFleetTicketEffect[]): IFleetTicketSync {
|
|
@@ -117,7 +129,7 @@ export class FleetTicketEffects {
|
|
|
117
129
|
if (records.length > 512) throw new Error('Too many fleet ticket effects; reconcile an exact event key.');
|
|
118
130
|
if (eventKey && !records.length) throw new Error('Fleet event is unavailable for this owner and ticket.');
|
|
119
131
|
const effects: IFleetTicketEffect[] = [];
|
|
120
|
-
for (const record of records) effects.push(await this.dispatch(FleetTicketEffectModel.exact.toPersisted(record), signal));
|
|
132
|
+
for (const record of records) effects.push((await this.dispatch(FleetTicketEffectModel.exact.toPersisted(record), signal)).effect);
|
|
121
133
|
return this.summary(effects);
|
|
122
134
|
}
|
|
123
135
|
public async operation(owner: string, ticketKey: string, actor: ITicketActor, operationId: string, result: IFleetLandResult | IFleetReleaseResult | IFleetToolingResult, signal?: AbortSignal): Promise<IFleetTicketSync> {
|
|
@@ -133,22 +145,24 @@ export class FleetTicketEffects {
|
|
|
133
145
|
// Save the entire release set before any ticket application.
|
|
134
146
|
for (const event of events) prepared.push(await this.prepare(owner, event));
|
|
135
147
|
const effects: IFleetTicketEffect[] = [];
|
|
136
|
-
for (const event of prepared) effects.push(await this.dispatch(event, signal));
|
|
148
|
+
for (const event of prepared) effects.push((await this.dispatch(event, signal)).effect);
|
|
137
149
|
return this.summary(effects);
|
|
138
150
|
}
|
|
139
|
-
|
|
151
|
+
/** Comments a worker's report on its ticket; the result names the comment that holds it. */
|
|
152
|
+
public async report(owner: string, ticketKey: string, actor: ITicketActor, worker: { name: string; harness: string; nativeId?: string }, report: { reportId?: string; text: string; truncated: boolean }, signal?: AbortSignal): Promise<IFleetTicketReport> {
|
|
140
153
|
if (!worker.nativeId || !report.reportId || !report.text.trim()) throw new Error('A report comment requires proven native session and report identities.');
|
|
141
154
|
const body = `Fleet report from ${worker.name} (${worker.harness}:${worker.nativeId}), report ${report.reportId}${report.truncated ? ' (truncated)' : ''}:\n\n${report.text}`;
|
|
142
155
|
const identity = plugins.crypto.createHash('sha256').update(JSON.stringify([owner, ticketKey, worker.harness, worker.nativeId, report.reportId, body])).digest('hex');
|
|
143
156
|
const event: TTicketFleetEvent = { ticketKey, actor: this.actor(actor), kind: 'comment', body, idempotencyKey: `fleet:report:${identity}` };
|
|
144
|
-
|
|
157
|
+
const { effect, commentId } = await this.dispatch(await this.prepare(owner, event, `${worker.harness}:${worker.nativeId}`), signal);
|
|
158
|
+
return { sync: this.summary([effect]), ...(commentId ? { commentId } : {}) };
|
|
145
159
|
}
|
|
146
160
|
public async lostDispatch(owner: string, worker: IFleetWorker, signal?: AbortSignal): Promise<IFleetTicketSync> {
|
|
147
161
|
const lost = worker.lostDispatch;
|
|
148
162
|
if (!lost || !worker.ticketKey) throw new Error('Lost dispatch comment requires a durable attributed resolution and ticket.');
|
|
149
163
|
const body = `Fleet worker ${worker.name} (${worker.harness}${worker.nativeId ? `:${worker.nativeId}` : ', no native identity'}): dispatch ${lost.dispatch?.id ?? 'unfenced native session'} (${lost.dispatch?.action ?? 'observation'}) resolved as lost at ${lost.resolvedAt}.\nReason: ${lost.reason}\nAbsence proof: ${JSON.stringify(lost.proof)}\nOriginal fence: ${JSON.stringify(lost.dispatch ?? null)}\nNo prompt or message was resent. Stop/archive are permitted.`;
|
|
150
164
|
const event: TTicketFleetEvent = { ticketKey: worker.ticketKey, actor: this.actor(lost.actor), kind: 'comment', body, idempotencyKey: `fleet:lost:${lost.id}` };
|
|
151
|
-
return this.summary([await this.dispatch(await this.prepare(owner, event, worker.nativeId ? `${worker.harness}:${worker.nativeId}` : undefined), signal)]);
|
|
165
|
+
return this.summary([(await this.dispatch(await this.prepare(owner, event, worker.nativeId ? `${worker.harness}:${worker.nativeId}` : undefined), signal)).effect]);
|
|
152
166
|
}
|
|
153
167
|
public async reconfirmedDispatch(owner: string, worker: IFleetWorker, signal?: AbortSignal): Promise<IFleetTicketSync> {
|
|
154
168
|
const reconfirmed = worker.reconfirmedDispatch;
|
|
@@ -159,7 +173,7 @@ export class FleetTicketEffects {
|
|
|
159
173
|
: `cannot prove whether the message arrived${proof.firstMessageAt ? ` (its first message since the fence arrived at ${proof.firstMessageAt})` : ''}`;
|
|
160
174
|
const body = `Fleet worker ${worker.name} (${worker.harness}:${worker.nativeId}): steer ${reconfirmed.dispatch.id} reconfirmed at ${reconfirmed.resolvedAt}. The native session is ${proof.status} and its transcript ${delivery}.\nReason: ${reconfirmed.reason}\nSession proof: ${JSON.stringify(proof)}\nOriginal fence: ${JSON.stringify(reconfirmed.dispatch)}\nNo prompt or message was resent.`;
|
|
161
175
|
const event: TTicketFleetEvent = { ticketKey: worker.ticketKey, actor: this.actor(reconfirmed.actor), kind: 'comment', body, idempotencyKey: `fleet:reconfirmed:${reconfirmed.id}` };
|
|
162
|
-
return this.summary([await this.dispatch(await this.prepare(owner, event, `${worker.harness}:${worker.nativeId}`), signal)]);
|
|
176
|
+
return this.summary([(await this.dispatch(await this.prepare(owner, event, `${worker.harness}:${worker.nativeId}`), signal)).effect]);
|
|
163
177
|
}
|
|
164
178
|
/** The ticket comment of the worker's saved re-bind; a replay rebuilds the same comment from the saved record alone. */
|
|
165
179
|
public async reboundSession(owner: string, worker: IFleetWorker, signal?: AbortSignal): Promise<IFleetTicketSync> {
|
|
@@ -170,6 +184,6 @@ export class FleetTicketEffects {
|
|
|
170
184
|
: `the inventory lists it under the worker's name in ${proof.cwd}; its transcript does not show that it continues ${proof.previous.nativeId}`;
|
|
171
185
|
const body = `Fleet worker ${worker.name} (${worker.harness}) re-bound from native session ${proof.previous.nativeId} (${proof.previous.status}) to ${proof.nativeId} (${proof.status}) at ${rebound.resolvedAt}: ${relation}.\nReason: ${rebound.reason}\nRe-bind proof: ${JSON.stringify(proof)}\nNothing was sent or resumed; the next steer wakes ${proof.nativeId} with its saved options.`;
|
|
172
186
|
const event: TTicketFleetEvent = { ticketKey: worker.ticketKey, actor: this.actor(rebound.actor), kind: 'comment', body, idempotencyKey: `fleet:rebound:${rebound.id}` };
|
|
173
|
-
return this.summary([await this.dispatch(await this.prepare(owner, event, `${worker.harness}:${worker.nativeId}`), signal)]);
|
|
187
|
+
return this.summary([(await this.dispatch(await this.prepare(owner, event, `${worker.harness}:${worker.nativeId}`), signal)).effect]);
|
|
174
188
|
}
|
|
175
189
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
2
|
import { controllerRuntimeIdKey, type IWorkerClaudeJobRequest, type TWorkerClaudeJobObservation } from '../../ts_interfaces/index.js';
|
|
3
|
-
import type { IFleetWorker, IFleetWorkerProfile, IFleetWorkerJob, IFleetNativeMessageReceipt, TFleetEventKind, TFleetWorkerAction, TFleetWorkerRequest, TFleetWorkerResponse, TFleetWorkerJobAction, IFleetWorkerReport, IFleetWorkerDispatchHolder } from '../../ts_interfaces/fleetworkers.js';
|
|
3
|
+
import type { IFleetEvent, IFleetWorker, IFleetWorkerProfile, IFleetWorkerJob, IFleetNativeMessageReceipt, TFleetEventKind, TFleetWorkerAction, TFleetWorkerRequest, TFleetWorkerResponse, TFleetWorkerJobAction, IFleetWorkerReport, IFleetWorkerDispatchHolder } from '../../ts_interfaces/fleetworkers.js';
|
|
4
4
|
import type { IFleetConfiguration, IFleetOperation, IFleetTicketSync } from '../../ts_interfaces/fleet.js';
|
|
5
5
|
import type { ITicketActor } from '../../ts_interfaces/tickets.js';
|
|
6
|
-
import type { FleetTicketEffects } from './classes.ticketeffects.js';
|
|
6
|
+
import type { FleetTicketEffects, IFleetTicketReport } from './classes.ticketeffects.js';
|
|
7
7
|
import type { IControllerSystemMetrics, IControllerFleetWorkerView, IReq_ControllerFleetWorkerRead, IControllerPermission, IControllerQuestion, TControllerPermissionReply } from '../../ts_interfaces/index.js';
|
|
8
8
|
import { FleetWorkerStore, type IFleetWorkerStore, type IFleetWorkerDocument } from './classes.workerstore.js';
|
|
9
9
|
import { FleetEvents } from './classes.events.js';
|
|
@@ -115,15 +115,21 @@ export class FleetWorkers {
|
|
|
115
115
|
try {
|
|
116
116
|
const backend = this.backends[worker.harness], observed = backend.observed?.(worker), excerpt = backend.observedExcerpt?.(worker);
|
|
117
117
|
const events = await this.backends[worker.harness].poll(worker, worker.profile);
|
|
118
|
+
const ticketed = this.options.ticketEffects && worker.ticketKey && worker.ticketActor;
|
|
118
119
|
for (const { nativeId: _nativeId, ...event } of events) {
|
|
119
|
-
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
120
|
+
const published = { ...event, workerId: worker.id, workerName: worker.name, ...(worker.ticketKey ? { ticketKey: worker.ticketKey } : {}) };
|
|
121
|
+
if (ticketed && event.kind === 'worker.report.mid_turn' && event.text && event.reportId) {
|
|
122
|
+
await this.reportTicket(owner, worker, { reportId: event.reportId, text: event.text, truncated: event.truncated ?? false }, published);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (ticketed && event.kind === 'worker.turn.ended') {
|
|
126
|
+
let report: IFleetWorkerReport;
|
|
127
|
+
// The turn's end is published whether or not its report can be observed.
|
|
128
|
+
try { report = await this.backends[worker.harness].observe(worker, worker.profile); }
|
|
129
|
+
catch (error) { this.events.publish(published, owner); throw error; }
|
|
130
|
+
if (report.reportId && report.text.trim()) { await this.reportTicket(owner, worker, report, published); continue; }
|
|
126
131
|
}
|
|
132
|
+
this.events.publish(published, owner);
|
|
127
133
|
}
|
|
128
134
|
if (this.options.ticketEffects && worker.ticketKey && worker.ticketActor) {
|
|
129
135
|
const baseline = `${worker.id}:${worker.nativeId}:${worker.ticketKey}`;
|
|
@@ -162,8 +168,9 @@ export class FleetWorkers {
|
|
|
162
168
|
let report: IFleetWorkerReport;
|
|
163
169
|
try { report = await backend.observe(worker, worker.profile); } catch { return; }
|
|
164
170
|
if (!report.reportId || !report.text.trim() || !fleetReportMarker.test(report.text)) return;
|
|
165
|
-
|
|
166
|
-
if (this.options.ticketEffects && worker.ticketKey && worker.ticketActor) await this.reportTicket(owner, worker, report);
|
|
171
|
+
const event: Omit<IFleetEvent, 'id' | 'occurredAt'> = { kind: 'worker.report.mid_turn', workerId: worker.id, workerName: worker.name, ...ticket, reportId: report.reportId, text: report.text, truncated: report.truncated, status: report.status };
|
|
172
|
+
if (this.options.ticketEffects && worker.ticketKey && worker.ticketActor) await this.reportTicket(owner, worker, report, event);
|
|
173
|
+
else this.events.publish(event, owner);
|
|
167
174
|
}
|
|
168
175
|
public releaseEvent(operation: IFleetOperation, owner: string, repo?: string): void {
|
|
169
176
|
if (operation.operation !== 'release' || !['succeeded', 'failed'].includes(operation.state)) return;
|
|
@@ -175,11 +182,16 @@ export class FleetWorkers {
|
|
|
175
182
|
this.events.publish({ kind: ticketSync.state === 'applied' ? 'ticket.applied' : ticketSync.state === 'failed' ? 'ticket.failed' : 'ticket.unknown',
|
|
176
183
|
ticketKey, ticketSync, ...(workerId ? { workerId } : {}), ...(operationId ? { operationId } : {}) }, owner);
|
|
177
184
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Comments the report on the worker's ticket first, then publishes `event`, naming the comment that holds its whole
|
|
187
|
+
* text when the board wrote it, and then the comment's ticket event.
|
|
188
|
+
*/
|
|
189
|
+
private async reportTicket(owner: string, worker: IFleetWorker, report: { reportId?: string; text: string; truncated: boolean }, event?: Omit<IFleetEvent, 'id' | 'occurredAt'>): Promise<IFleetTicketSync> {
|
|
190
|
+
let written: IFleetTicketReport;
|
|
191
|
+
try { written = await this.options.ticketEffects!.report(owner, worker.ticketKey!, worker.ticketActor!, worker, report, this.abort.signal); }
|
|
192
|
+
catch (error) { written = { sync: { state: 'unknown', effects: [], error: this.error(error) } }; }
|
|
193
|
+
if (event) this.events.publish({ ...event, ...(written.commentId ? { commentId: written.commentId } : {}) }, owner);
|
|
194
|
+
this.ticketEvent(owner, worker.ticketKey!, written.sync, worker.id); return written.sync;
|
|
183
195
|
}
|
|
184
196
|
/** `reportsTo` names the direct report whose session asks; a worker it starts or adopts reports to it. */
|
|
185
197
|
public async execute<T extends TFleetWorkerAction>(action: T, request: TFleetWorkerRequest<T>, configuration: IFleetConfiguration, owner: string, signal?: AbortSignal, actor?: ITicketActor, reportsTo?: string): Promise<TFleetWorkerResponse<T>> {
|
|
@@ -117,7 +117,7 @@ const job = plugins.z.object({ id: text, action: plugins.z.enum(['start', 'adopt
|
|
|
117
117
|
const event = plugins.z.object({ id: plugins.z.number().int().positive(),
|
|
118
118
|
kind: plugins.z.enum(['worker.changed', 'worker.turn.ended', 'worker.report.mid_turn', 'worker.reaped', 'worker.credential.failed', 'worker.observation.failed', 'release.finished', 'release.failed', 'ticket.applied', 'ticket.failed', 'ticket.unknown',
|
|
119
119
|
'ticket.changed', 'ticket.notified', 'ticket.notification.failed', 'ticket.stalled', 'harness.release.discovered', 'host.load']),
|
|
120
|
-
occurredAt: text, workerId: text.optional(), workerName: name.optional(), sessionId: text.optional(), reportId: text.optional(), turnId: text.optional(), status: text.optional(),
|
|
120
|
+
occurredAt: text, workerId: text.optional(), workerName: name.optional(), sessionId: text.optional(), reportId: text.optional(), commentId: text.optional(), turnId: text.optional(), status: text.optional(),
|
|
121
121
|
ticketKey: ticketKey.optional(),
|
|
122
122
|
ticketSync: ticketSync.optional(),
|
|
123
123
|
text: plugins.z.string().max(64 * 1024).optional(), truncated: plugins.z.boolean().optional(), error: plugins.z.string().max(4096).optional(),
|