@myagentroam/node 0.9.3 → 0.9.4

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.
@@ -8,14 +8,55 @@ export function hasManagedActiveRun(runtime, session) {
8
8
  .listRunsForSession(session.id)
9
9
  .some((run) => run.status === 'STARTING' || run.status === 'RUNNING' || run.status === 'CANCELLING'));
10
10
  }
11
+ export function convergeConversationWatchPage(previous, previousObserved, next, previousAuthority) {
12
+ const authority = isConversationHistorySource(previousAuthority)
13
+ ? previousAuthority
14
+ : previous?.source;
15
+ if (previous === undefined ||
16
+ authority === undefined ||
17
+ sourceRank(next.source) >= sourceRank(authority))
18
+ return { page: next, authority: next.source };
19
+ if (previous.turns.length === 0)
20
+ return { page: next, authority };
21
+ const boundary = next.turns.findLastIndex((turn) => previous.turns.some((candidate) => sameConversationTurn(candidate, turn)));
22
+ const observedBoundary = boundary >= 0 || previousObserved === undefined
23
+ ? -1
24
+ : next.turns.findLastIndex((turn) => previousObserved.turns.some((candidate) => sameConversationTurn(candidate, turn)));
25
+ const safeBoundary = Math.max(boundary, observedBoundary);
26
+ if (safeBoundary < 0)
27
+ return { page: previous, authority };
28
+ const additions = next.turns
29
+ .slice(safeBoundary + 1)
30
+ .filter((turn) => !previous.turns.some((candidate) => sameConversationTurn(candidate, turn)));
31
+ if (additions.length === 0)
32
+ return { page: previous, authority };
33
+ return {
34
+ page: {
35
+ ...previous,
36
+ source: next.source,
37
+ snapshotSequence: Math.max(previous.snapshotSequence, next.snapshotSequence),
38
+ readAt: next.readAt,
39
+ latestVisibleAt: next.latestVisibleAt ?? previous.latestVisibleAt,
40
+ turns: [...previous.turns, ...additions]
41
+ },
42
+ authority
43
+ };
44
+ }
45
+ function isConversationHistorySource(value) {
46
+ return value === 'official' || value === 'native' || value === 'runtime';
47
+ }
11
48
  export class ConversationHistoryService {
12
49
  options;
50
+ activeOfficialReads = new Map();
13
51
  constructor(options) {
14
52
  this.options = options;
15
53
  }
16
54
  async read(session, input) {
17
55
  const snapshotSequence = this.options.snapshotSequence();
18
56
  const readAt = Date.now();
57
+ const activeCursor = decodeActiveHistoryCursor(input.cursor, session.id);
58
+ if (activeCursor !== undefined || this.options.hasManagedActiveRun(session))
59
+ return this.readManagedActive(session, input, activeCursor, snapshotSequence, readAt);
19
60
  let official = await this.readOfficial(session, input);
20
61
  if (official !== undefined) {
21
62
  const limit = Math.min(Math.max(input.limit ?? 30, 1), 500);
@@ -23,13 +64,11 @@ export class ConversationHistoryService {
23
64
  ? []
24
65
  : this.options.runtime.listConversationTurns({ sessionId: session.id, limit: 500 }).turns;
25
66
  const initialOfficialRunIds = new Set(official.turns.flatMap((turn) => (turn.runId === null ? [] : [turn.runId])));
26
- const absentLocalTurns = runtimeTurns.filter((turn) => turn.runId === null || !initialOfficialRunIds.has(turn.runId));
27
- const officialFloor = Math.min(...official.turns.map((turn) => Math.max(turn.startedAt ?? 0, turn.completedAt ?? 0)));
28
- const missingLocalTurns = official.turns.length === 0
29
- ? absentLocalTurns.slice(-limit)
30
- : officialFloor > 0
31
- ? absentLocalTurns.filter((turn) => Math.max(turn.startedAt ?? 0, turn.completedAt ?? 0) >= officialFloor)
32
- : absentLocalTurns.slice(-1);
67
+ const lastRepresentedRuntimeIndex = runtimeTurns.findLastIndex((turn) => turn.runId !== null && initialOfficialRunIds.has(turn.runId));
68
+ const officialHasTurns = official.turns.length > 0;
69
+ const absentLocalTurns = runtimeTurns.filter((turn, index) => (turn.runId === null || !initialOfficialRunIds.has(turn.runId)) &&
70
+ (!officialHasTurns || index > lastRepresentedRuntimeIndex));
71
+ const missingLocalTurns = absentLocalTurns.slice(-limit);
33
72
  if (input.cursor === undefined &&
34
73
  missingLocalTurns.length > 0 &&
35
74
  official.turns.length + missingLocalTurns.length > limit) {
@@ -49,7 +88,7 @@ export class ConversationHistoryService {
49
88
  pendingItemsByRunId.set(turn.runId, pendingItems);
50
89
  }
51
90
  const availableLocalSlots = Math.max(0, limit - officialTurns.length);
52
- const localCandidates = (input.cursor === undefined ? missingLocalTurns : []).sort((left, right) => (left.startedAt ?? 0) - (right.startedAt ?? 0));
91
+ const localCandidates = input.cursor === undefined ? missingLocalTurns : [];
53
92
  const localTurns = availableLocalSlots === 0 ? [] : localCandidates.slice(-availableLocalSlots);
54
93
  const localRequestIds = new Set([
55
94
  ...localTurns.flatMap((turn) => turn.items.flatMap((item) => {
@@ -78,7 +117,7 @@ export class ConversationHistoryService {
78
117
  })
79
118
  .filter((turn) => turn.items.length > 0),
80
119
  ...localTurns
81
- ].sort((left, right) => (left.startedAt ?? 0) - (right.startedAt ?? 0));
120
+ ];
82
121
  return this.page(official.nextCursor, turns, {
83
122
  snapshotSequence,
84
123
  source: 'official',
@@ -119,6 +158,86 @@ export class ConversationHistoryService {
119
158
  const page = this.options.runtime.listConversationTurns({ sessionId: session.id, ...input });
120
159
  return this.page(page.nextCursor, page.turns, { snapshotSequence, source: 'runtime', readAt });
121
160
  }
161
+ async readManagedActive(session, input, cursor, snapshotSequence, readAt) {
162
+ const limit = Math.min(Math.max(input.limit ?? 30, 1), 500);
163
+ const runtimeTurns = this.options.runtime.listConversationTurns({
164
+ sessionId: session.id,
165
+ limit: 500
166
+ }).turns;
167
+ const beforeAt = cursor?.beforeAt ?? oldestTurnActivityAt(runtimeTurns);
168
+ if (cursor?.stage === 'OFFICIAL') {
169
+ const prefetched = this.takeActiveOfficialRead(session.id, limit, cursor.cursor);
170
+ const official = await (prefetched ??
171
+ this.readOfficial(session, {
172
+ ...(cursor.cursor === null ? {} : { cursor: cursor.cursor }),
173
+ limit
174
+ }));
175
+ if (official === undefined)
176
+ return this.page(null, [], { snapshotSequence, source: 'runtime', readAt });
177
+ const turns = (await this.attachNativeImages(session, official.turns)).filter((turn) => latestTurnActivityAt(turn) < beforeAt);
178
+ return this.page(official.nextCursor === null
179
+ ? null
180
+ : encodeActiveHistoryCursor({
181
+ sessionId: session.id,
182
+ stage: 'OFFICIAL',
183
+ beforeAt,
184
+ cursor: official.nextCursor
185
+ }), turns, { snapshotSequence, source: 'official', readAt });
186
+ }
187
+ if (cursor === undefined && session.externalSessionId !== null)
188
+ this.prefetchActiveOfficial(session, limit);
189
+ const runtimePage = this.options.runtime.listConversationTurns({
190
+ sessionId: session.id,
191
+ ...(cursor?.cursor === null || cursor === undefined ? {} : { cursor: cursor.cursor }),
192
+ limit
193
+ });
194
+ const nextCursor = runtimePage.nextCursor !== null
195
+ ? encodeActiveHistoryCursor({
196
+ sessionId: session.id,
197
+ stage: 'RUNTIME',
198
+ beforeAt,
199
+ cursor: runtimePage.nextCursor
200
+ })
201
+ : session.externalSessionId === null
202
+ ? null
203
+ : encodeActiveHistoryCursor({
204
+ sessionId: session.id,
205
+ stage: 'OFFICIAL',
206
+ beforeAt,
207
+ cursor: null
208
+ });
209
+ return this.page(nextCursor, runtimePage.turns, {
210
+ snapshotSequence,
211
+ source: 'runtime',
212
+ readAt,
213
+ deferredOlderHistory: true
214
+ });
215
+ }
216
+ prefetchActiveOfficial(session, limit) {
217
+ const now = Date.now();
218
+ for (const [sessionId, read] of this.activeOfficialReads)
219
+ if (read.createdAt + 60_000 <= now)
220
+ this.activeOfficialReads.delete(sessionId);
221
+ const current = this.activeOfficialReads.get(session.id);
222
+ if (current !== undefined && current.limit === limit)
223
+ return;
224
+ this.activeOfficialReads.set(session.id, {
225
+ createdAt: now,
226
+ limit,
227
+ // The active Run fast path must stay independent from an optional
228
+ // background reader failure until the user explicitly requests older history.
229
+ page: this.readOfficial(session, { limit }).catch(() => undefined)
230
+ });
231
+ }
232
+ takeActiveOfficialRead(sessionId, limit, cursor) {
233
+ if (cursor !== null)
234
+ return undefined;
235
+ const read = this.activeOfficialReads.get(sessionId);
236
+ if (read === undefined || read.limit !== limit || read.createdAt + 60_000 <= Date.now())
237
+ return undefined;
238
+ this.activeOfficialReads.delete(sessionId);
239
+ return read.page;
240
+ }
122
241
  async refreshExternalActivity(session) {
123
242
  const runner = this.options.runners.require(session.runner);
124
243
  if (!runner.available(this.options.capabilities())) {
@@ -187,3 +306,56 @@ export class ConversationHistoryService {
187
306
  };
188
307
  }
189
308
  }
309
+ const ACTIVE_HISTORY_CURSOR_PREFIX = 'mar-active-history:1:';
310
+ function encodeActiveHistoryCursor(cursor) {
311
+ return `${ACTIVE_HISTORY_CURSOR_PREFIX}${Buffer.from(JSON.stringify(cursor)).toString('base64url')}`;
312
+ }
313
+ function decodeActiveHistoryCursor(value, sessionId) {
314
+ if (value === undefined || !value.startsWith(ACTIVE_HISTORY_CURSOR_PREFIX))
315
+ return undefined;
316
+ try {
317
+ const decoded = JSON.parse(Buffer.from(value.slice(ACTIVE_HISTORY_CURSOR_PREFIX.length), 'base64url').toString('utf8'));
318
+ if (decoded.sessionId !== sessionId ||
319
+ (decoded.stage !== 'RUNTIME' && decoded.stage !== 'OFFICIAL') ||
320
+ typeof decoded.beforeAt !== 'number' ||
321
+ !Number.isFinite(decoded.beforeAt) ||
322
+ decoded.beforeAt < 0 ||
323
+ (decoded.cursor !== null && typeof decoded.cursor !== 'string'))
324
+ throw new Error('EVENT_CURSOR_INVALID');
325
+ return decoded;
326
+ }
327
+ catch {
328
+ throw new Error('EVENT_CURSOR_INVALID');
329
+ }
330
+ }
331
+ function latestTurnActivityAt(turn) {
332
+ return Math.max(turn.startedAt ?? 0, turn.completedAt ?? 0);
333
+ }
334
+ function oldestTurnActivityAt(turns) {
335
+ return turns.length === 0 ? 0 : Math.min(...turns.map(latestTurnActivityAt));
336
+ }
337
+ function sameConversationTurn(left, right) {
338
+ if (left.id === right.id)
339
+ return true;
340
+ if (left.runId !== null && left.runId === right.runId)
341
+ return true;
342
+ const leftMessageId = conversationClientMessageId(left);
343
+ return leftMessageId !== null && leftMessageId === conversationClientMessageId(right);
344
+ }
345
+ function conversationClientMessageId(turn) {
346
+ for (const item of turn.items) {
347
+ if (item.kind !== 'user_message' || !isPlainRecord(item.payload))
348
+ continue;
349
+ const value = item.payload.clientMessageId;
350
+ if (typeof value === 'string' && value.length > 0)
351
+ return value;
352
+ }
353
+ return null;
354
+ }
355
+ function sourceRank(source) {
356
+ if (source === 'official')
357
+ return 3;
358
+ if (source === 'native')
359
+ return 2;
360
+ return 1;
361
+ }
@@ -16,6 +16,7 @@ export function conversationSegments(turn) {
16
16
  const latest = index === groups.length - 1;
17
17
  const projectedItems = items.map((item) => ({
18
18
  ...item,
19
+ runId: turn.runId,
19
20
  segmentId: id,
20
21
  segmentIndex: index
21
22
  }));
@@ -89,7 +90,10 @@ export class ConversationSegmentService {
89
90
  snapshotSequence: page.snapshotSequence,
90
91
  source: page.source,
91
92
  readAt: page.readAt,
92
- latestVisibleAt: page.latestVisibleAt
93
+ latestVisibleAt: page.latestVisibleAt,
94
+ ...(boundary === undefined && page.deferredOlderHistory === true
95
+ ? { deferredOlderHistory: true }
96
+ : {})
93
97
  };
94
98
  const pageSegments = page.turns.flatMap(conversationSegments);
95
99
  let eligible = pageSegments;
@@ -108,6 +112,8 @@ export class ConversationSegmentService {
108
112
  hasOlder = eligible.length > take.length || page.nextCursor !== null;
109
113
  collected.unshift(...take);
110
114
  }
115
+ if (boundary === undefined && page.deferredOlderHistory === true)
116
+ break;
111
117
  if (collected.length >= limit || page.nextCursor === null)
112
118
  break;
113
119
  if (visited.has(page.nextCursor))
@@ -2,18 +2,24 @@ import type { NodeAgentSession } from '../database.js';
2
2
  import type { NodeOperationHandler } from '../connector/node-operation-router.js';
3
3
  export interface NativeSessionWatchOptions<TPage extends {
4
4
  readonly turns: readonly unknown[];
5
+ readonly source?: unknown;
5
6
  }> {
6
7
  readonly resolve: (sessionId: string) => Promise<NodeAgentSession | undefined>;
7
8
  readonly managedActive: (session: NodeAgentSession) => boolean;
8
9
  readonly refreshActivity: (session: NodeAgentSession) => Promise<void>;
9
10
  readonly present: (session: NodeAgentSession) => unknown;
10
11
  readonly readPage: (session: NodeAgentSession, limit: number) => Promise<TPage>;
12
+ readonly convergePage: (previous: TPage | undefined, previousObserved: TPage | undefined, next: TPage, previousAuthority: unknown) => {
13
+ readonly page: TPage;
14
+ readonly authority: unknown;
15
+ };
11
16
  readonly projectInitialPage?: (session: NodeAgentSession, page: TPage, unit: 'SEGMENT', limit: number) => unknown;
12
17
  readonly emitSession: (session: unknown) => void;
13
- readonly emitTurn: (turn: unknown) => void;
18
+ readonly emitPage: (session: NodeAgentSession, page: TPage) => void;
14
19
  }
15
20
  export declare class NativeSessionWatchService<TPage extends {
16
21
  readonly turns: readonly unknown[];
22
+ readonly source?: unknown;
17
23
  }> {
18
24
  private readonly options;
19
25
  private readonly watches;
@@ -33,6 +39,6 @@ export declare class NativeSessionWatchService<TPage extends {
33
39
  readonly expiresAt: number;
34
40
  } | undefined;
35
41
  get size(): number;
36
- refresh(sessionId: string, limit?: number): Promise<TPage | undefined>;
42
+ refresh(sessionId: string, limit?: number, emit?: boolean): Promise<TPage | undefined>;
37
43
  private performRefresh;
38
44
  }
@@ -3,6 +3,7 @@ const INTERVAL_MS = 2_000;
3
3
  const TTL_MS = 45_000;
4
4
  const MAX_FAILURES = 3;
5
5
  const INITIAL_TURN_LIMIT = 30;
6
+ const EMITTED_TURN_LIMIT = 10;
6
7
  export class NativeSessionWatchService {
7
8
  options;
8
9
  watches = new Map();
@@ -32,7 +33,7 @@ export class NativeSessionWatchService {
32
33
  return { renewed: true };
33
34
  }
34
35
  const watch = this.watch(session);
35
- const turnSnapshot = watch === undefined ? undefined : await this.refresh(session.id, INITIAL_TURN_LIMIT);
36
+ const turnSnapshot = watch === undefined ? undefined : await this.refresh(session.id, INITIAL_TURN_LIMIT, false);
36
37
  if (watch !== undefined && turnSnapshot === undefined) {
37
38
  this.stop(session.id);
38
39
  throw new Error('NATIVE_TRANSCRIPT_UNAVAILABLE');
@@ -68,6 +69,9 @@ export class NativeSessionWatchService {
68
69
  refresh: undefined,
69
70
  sessionSignature: undefined,
70
71
  signature: undefined,
72
+ page: undefined,
73
+ observedPage: undefined,
74
+ authority: undefined,
71
75
  failures: 0,
72
76
  timer: undefined
73
77
  };
@@ -110,7 +114,7 @@ export class NativeSessionWatchService {
110
114
  get size() {
111
115
  return this.watches.size;
112
116
  }
113
- async refresh(sessionId, limit = 10) {
117
+ async refresh(sessionId, limit = 10, emit = true) {
114
118
  const watch = this.watches.get(sessionId);
115
119
  if (watch === undefined)
116
120
  return undefined;
@@ -120,7 +124,7 @@ export class NativeSessionWatchService {
120
124
  this.stop(sessionId);
121
125
  return undefined;
122
126
  }
123
- const refresh = this.performRefresh(sessionId, watch, limit);
127
+ const refresh = this.performRefresh(sessionId, watch, limit, emit);
124
128
  watch.refresh = refresh;
125
129
  try {
126
130
  return await refresh;
@@ -130,7 +134,7 @@ export class NativeSessionWatchService {
130
134
  watch.refresh = undefined;
131
135
  }
132
136
  }
133
- async performRefresh(sessionId, watch, limit) {
137
+ async performRefresh(sessionId, watch, limit, emit) {
134
138
  try {
135
139
  const session = await this.options.resolve(sessionId);
136
140
  if (session === undefined ||
@@ -148,14 +152,28 @@ export class NativeSessionWatchService {
148
152
  }
149
153
  const page = await this.options.readPage(session, limit);
150
154
  watch.failures = 0;
151
- const turns = page.turns.length <= 10 ? page.turns : page.turns.slice(-10);
152
- const signature = JSON.stringify(turns);
155
+ const turns = page.turns.length <= EMITTED_TURN_LIMIT
156
+ ? page.turns
157
+ : page.turns.slice(-EMITTED_TURN_LIMIT);
158
+ const observed = { ...page, turns };
159
+ const convergence = this.options.convergePage(watch.page, watch.observedPage, observed, watch.authority);
160
+ const converged = convergence.page;
161
+ const accepted = converged.turns.length <= EMITTED_TURN_LIMIT + 1
162
+ ? converged
163
+ : {
164
+ ...converged,
165
+ turns: converged.turns.slice(-(EMITTED_TURN_LIMIT + 1))
166
+ };
167
+ watch.observedPage = observed;
168
+ watch.authority = convergence.authority;
169
+ const signature = JSON.stringify({ source: accepted.source, turns: accepted.turns });
153
170
  if (signature === watch.signature)
154
- return page;
171
+ return accepted;
155
172
  watch.signature = signature;
156
- for (const turn of turns)
157
- this.options.emitTurn(turn);
158
- return page;
173
+ watch.page = accepted;
174
+ if (emit)
175
+ this.options.emitPage(session, accepted);
176
+ return emit ? accepted : page;
159
177
  }
160
178
  catch {
161
179
  watch.failures += 1;
@@ -23,7 +23,8 @@ export class NodeRequestService {
23
23
  ? {
24
24
  ...payload.data,
25
25
  __workspaceWatchSessionHash: payload.userAuthorization
26
- .sessionHash
26
+ .sessionHash,
27
+ __requestUserId: payload.userAuthorization.userId
27
28
  }
28
29
  : payload.data;
29
30
  const result = await this.options.execute(payload.operation, data);
@@ -95,11 +95,19 @@ export class RunEventService {
95
95
  this.options.emitWorkbench('conversation', {
96
96
  event: { ...event, createdAt: persisted?.createdAt ?? Date.now() }
97
97
  });
98
- if (notificationPayload !== undefined)
99
- this.emitNow(runId, 'notification', {
100
- ...notificationPayload,
101
- occurredAt: persisted?.createdAt ?? Date.now()
98
+ if (notificationPayload !== undefined) {
99
+ const occurredAt = persisted?.createdAt ?? Date.now();
100
+ nodeLog('run.event', { runId, eventType: 'notification' });
101
+ this.options.emitWorkbench('conversation', {
102
+ event: {
103
+ runId,
104
+ sequence: event.sequence,
105
+ eventType: 'notification',
106
+ payload: { ...notificationPayload, occurredAt },
107
+ createdAt: occurredAt
108
+ }
102
109
  });
110
+ }
103
111
  if (status === undefined || !isTerminalRunStatus(status))
104
112
  return;
105
113
  const turn = this.options.runtime.conversationTurnForRun(runId);
@@ -126,11 +134,14 @@ export class RunEventService {
126
134
  if (session === undefined)
127
135
  return undefined;
128
136
  const base = {
137
+ userId: run.initiatedByUserId,
129
138
  workspaceId: run.workspaceId,
130
139
  sessionId: run.sessionId,
131
140
  sessionTitle: session.customTitle ?? session.runnerTitle ?? session.id,
132
141
  runId
133
142
  };
143
+ if (!Number.isSafeInteger(base.userId))
144
+ return undefined;
134
145
  if (status !== undefined && isTerminalRunStatus(status)) {
135
146
  if (this.options.state.interruptRequested.has(runId))
136
147
  return undefined;
@@ -159,7 +170,7 @@ export class RunEventService {
159
170
  if (payload.kind === 'plan' && typeof payload.itemId === 'string')
160
171
  return {
161
172
  ...base,
162
- notificationId: `plan:${run.workspaceId}:${run.sessionId}`,
173
+ notificationId: `plan:${run.workspaceId}:${runId}:${payload.itemId}`,
163
174
  kind: 'PLAN_READY'
164
175
  };
165
176
  if (payload.kind === 'user_input_request' &&
@@ -112,25 +112,16 @@ export class SessionLifecycleService {
112
112
  async updateConfiguration(input) {
113
113
  if (typeof input.sessionId !== 'string' ||
114
114
  typeof input.model !== 'string' ||
115
+ !validSessionOption(input.model) ||
115
116
  typeof input.effort !== 'string' ||
116
117
  !validSessionEffort(input.effort) ||
117
- typeof input.access !== 'string')
118
+ typeof input.access !== 'string' ||
119
+ !validSessionOption(input.access))
118
120
  throw new Error('SESSION_INVALID');
119
121
  const current = this.options.runtime.getAgentSession(input.sessionId) ??
120
122
  (await this.options.projection.createMetadataProjection(input.sessionId));
121
123
  if (current === undefined)
122
124
  throw new Error('SESSION_NOT_FOUND');
123
- const configuration = {
124
- runner: current.runner,
125
- model: input.model,
126
- effort: input.effort,
127
- access: input.access
128
- };
129
- const workspace = this.options.database().getWorkspace(current.workspaceId);
130
- const environment = parseSecretEnvironment(input.secretEnvironment);
131
- if (workspace === undefined ||
132
- !this.options.runners.supportsConfiguration(configuration, workspace, environment))
133
- throw new Error('RUNNER_CONFIGURATION_UNSUPPORTED');
134
125
  const session = this.options.runtime.updateAgentSessionConfiguration({
135
126
  sessionId: current.id,
136
127
  model: input.model,
@@ -209,6 +200,7 @@ export class SessionLifecycleService {
209
200
  sessionId: messageSessionId,
210
201
  clientMessageId: input.clientMessageId,
211
202
  content: input.content,
203
+ ...(input.__requestUserId === undefined ? {} : { __requestUserId: input.__requestUserId }),
212
204
  ...(input.secretEnvironment === undefined
213
205
  ? {}
214
206
  : { secretEnvironment: input.secretEnvironment }),
@@ -23,6 +23,7 @@ interface SessionMessageServiceOptions {
23
23
  }
24
24
  export declare class SessionMessageService {
25
25
  private readonly options;
26
+ private readonly pendingMessages;
26
27
  constructor(options: SessionMessageServiceOptions);
27
28
  operations(): Readonly<Record<string, NodeOperationHandler>>;
28
29
  private message;