@myagentroam/node 0.10.61 → 0.10.63

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.
@@ -0,0 +1,195 @@
1
+ import { hostedConversationStorePath, nodeDataDirectory } from '../config.js';
2
+ import { HostedConversationStore } from './hosted-conversation-store.js';
3
+ import { ConversationTemplateNodeService } from './conversation-template-node-service.js';
4
+ import { HostedConversationNodeService } from './hosted-conversation-node-service.js';
5
+ import { ConversationHostingMaintenanceService } from './conversation-hosting-maintenance-service.js';
6
+ import { MarAgentHostedConversationRuntime } from '../runner/mar-agent/hosted-conversation-runtime.js';
7
+ import { BubblewrapSandboxExecutor } from '@myagentroam/agent/sandbox/linux';
8
+ import { hostedConversationSandboxProfile } from './hosted-conversation-sandbox-profile.js';
9
+ import { conversationHostingCapability } from './conversation-hosting-capability-service.js';
10
+ import { nodeLog } from '../operational.js';
11
+ import { safeErrorCode } from '../util/safe-error.js';
12
+ /** Owns the hosting resource lifetime, including partial initialization and hot reconfiguration. */
13
+ export class ConversationHostingLifecycleService {
14
+ options;
15
+ resources;
16
+ failureCode;
17
+ initializing;
18
+ closing;
19
+ closed = false;
20
+ constructor(options) {
21
+ this.options = options;
22
+ }
23
+ handler(operation) {
24
+ return this.resources?.operations[operation];
25
+ }
26
+ prepareForDisable() {
27
+ return this.resources?.service.prepareForDisable() ?? (this.resources ? undefined : () => { });
28
+ }
29
+ initialize() {
30
+ if (this.closed)
31
+ return Promise.resolve();
32
+ const work = (this.initializing ?? Promise.resolve()).then(() => this.rebuild());
33
+ this.initializing = work;
34
+ return work.finally(() => {
35
+ if (this.initializing === work)
36
+ this.initializing = undefined;
37
+ });
38
+ }
39
+ dispose() {
40
+ if (this.closing)
41
+ return this.closing;
42
+ this.closed = true;
43
+ const current = this.resources;
44
+ this.resources = undefined;
45
+ this.closing = (async () => {
46
+ try {
47
+ await this.initializing;
48
+ }
49
+ finally {
50
+ await this.release(current);
51
+ }
52
+ })();
53
+ return this.closing;
54
+ }
55
+ async release(resources) {
56
+ if (!resources)
57
+ return;
58
+ try {
59
+ await resources.maintenance.dispose();
60
+ }
61
+ finally {
62
+ resources.service.dispose();
63
+ await resources.runtime.dispose().catch(() => undefined);
64
+ }
65
+ }
66
+ async rebuild() {
67
+ if (this.closed)
68
+ return;
69
+ const previous = this.resources;
70
+ this.resources = undefined;
71
+ this.failureCode = undefined;
72
+ await this.release(previous);
73
+ if (this.closed)
74
+ return;
75
+ const config = this.options.config();
76
+ const database = this.options.database();
77
+ if (config?.conversationHosting?.enabled === true &&
78
+ this.options.capabilities().platform === 'linux') {
79
+ if (config.nodeId === undefined) {
80
+ this.failureCode = 'NODE_UNAUTHENTICATED';
81
+ }
82
+ else {
83
+ let runtime;
84
+ let hostedService;
85
+ let maintenanceService;
86
+ try {
87
+ if (database === undefined)
88
+ throw new Error('NODE_DATABASE_UNAVAILABLE');
89
+ const rootDirectory = hostedConversationStorePath(config, this.options.configPath);
90
+ if (rootDirectory === undefined)
91
+ throw new Error('CONVERSATION_HOSTING_STORAGE_UNAVAILABLE');
92
+ const store = new HostedConversationStore({
93
+ dataDirectory: nodeDataDirectory(config, this.options.configPath),
94
+ rootDirectory,
95
+ nodeId: config.nodeId,
96
+ database: database
97
+ });
98
+ await store.initialize();
99
+ const templateService = new ConversationTemplateNodeService({
100
+ database: database,
101
+ store,
102
+ nodeId: () => this.options.config()?.nodeId ?? 'unregistered'
103
+ });
104
+ await templateService.recoverInterruptedPublications();
105
+ await store.rebuildConversationIndex();
106
+ runtime = new MarAgentHostedConversationRuntime({
107
+ ...this.options.runtime
108
+ });
109
+ hostedService = new HostedConversationNodeService({
110
+ ...this.options.host,
111
+ database: database,
112
+ store,
113
+ runtime,
114
+ nodeId: () => this.options.config()?.nodeId ?? 'unregistered',
115
+ nodeAppVersion: () => this.options.capabilities().nodeAppVersion ?? 'unknown',
116
+ processExecutorFactory: (input) => new BubblewrapSandboxExecutor({
117
+ profile: hostedConversationSandboxProfile(input)
118
+ }),
119
+ assertCanCreate: () => {
120
+ if (maintenanceService === undefined)
121
+ throw new Error('CONVERSATION_HOSTING_UNAVAILABLE');
122
+ return maintenanceService.assertCanCreate();
123
+ }
124
+ });
125
+ maintenanceService = new ConversationHostingMaintenanceService({
126
+ store,
127
+ inactiveRetentionDays: config.conversationHosting.inactiveRetentionDays ?? 60,
128
+ isConversationInUse: (conversationId) => hostedService?.isConversationInUse(conversationId) ?? true,
129
+ beginConversationCleanup: (conversationId) => hostedService?.beginConversationCleanup(conversationId),
130
+ releaseConversation: (conversation) => {
131
+ if (hostedService === undefined)
132
+ throw new Error('CONVERSATION_HOSTING_UNAVAILABLE');
133
+ return hostedService.releaseConversationForCleanup(conversation);
134
+ },
135
+ onAutomaticCleanupError: (error) => nodeLog('conversation.hosting.cleanup.failed', {
136
+ nodeId: config.nodeId,
137
+ code: safeErrorCode(error, 'CONVERSATION_HOSTING_CLEANUP_FAILED')
138
+ })
139
+ });
140
+ await maintenanceService.start();
141
+ if (this.closed) {
142
+ await this.release({
143
+ runtime,
144
+ service: hostedService,
145
+ maintenance: maintenanceService,
146
+ operations: {}
147
+ });
148
+ return;
149
+ }
150
+ this.resources = {
151
+ runtime,
152
+ service: hostedService,
153
+ maintenance: maintenanceService,
154
+ operations: {
155
+ ...templateService.operations(),
156
+ ...hostedService.operations(),
157
+ ...maintenanceService.operations()
158
+ }
159
+ };
160
+ }
161
+ catch (error) {
162
+ await maintenanceService?.dispose();
163
+ hostedService?.dispose();
164
+ await runtime?.dispose().catch(() => undefined);
165
+ if (this.closed)
166
+ return;
167
+ this.failureCode = safeErrorCode(error, 'CONVERSATION_HOSTING_RECOVERY_FAILED');
168
+ nodeLog('conversation.hosting.initialization.failed', {
169
+ nodeId: config.nodeId,
170
+ code: this.failureCode
171
+ });
172
+ }
173
+ }
174
+ }
175
+ }
176
+ async project(capabilities) {
177
+ const current = this.resources;
178
+ let ready = current !== undefined;
179
+ let failureCode = this.failureCode;
180
+ let capacity = 'AVAILABLE';
181
+ if (current) {
182
+ try {
183
+ capacity = (await current.maintenance.storageCapacity()).capacity;
184
+ }
185
+ catch (error) {
186
+ ready = false;
187
+ failureCode = safeErrorCode(error, 'CONVERSATION_HOSTING_STORAGE_UNAVAILABLE');
188
+ }
189
+ }
190
+ const conversationHosting = await conversationHostingCapability(capabilities, this.options.config(), ready, failureCode, this.options.probe, capacity);
191
+ if (current !== this.resources)
192
+ return this.project(capabilities);
193
+ return { ...capabilities, conversationHosting };
194
+ }
195
+ }
@@ -0,0 +1,240 @@
1
+ import { isPlainRecord } from '../util/node-operation-parsers.js';
2
+ import { composeConversationRuntimePage, convergeConversationWatchPage } from './conversation-history-service.js';
3
+ const INITIAL_TURN_LIMIT = 30;
4
+ const EMITTED_TURN_LIMIT = 10;
5
+ /** Conversation state shared by a Session's leases; no timers or transport ownership. */
6
+ export class ConversationObservation {
7
+ session;
8
+ options;
9
+ mode;
10
+ page;
11
+ observedPage;
12
+ authority;
13
+ signature;
14
+ runtimePage;
15
+ runtimeRevision;
16
+ runtimeRead;
17
+ historyRead;
18
+ refreshRead;
19
+ refreshMode;
20
+ eventRevision = 0;
21
+ transitionGeneration = 0;
22
+ closed = false;
23
+ pendingRuntimeSnapshot = false;
24
+ stage = 'read_page';
25
+ constructor(session, options) {
26
+ this.session = session;
27
+ this.options = options;
28
+ this.mode = options.managedActive(session) ? 'RUNNING' : 'HISTORY';
29
+ }
30
+ get snapshotPending() {
31
+ return this.mode === 'RUNNING' && this.pendingRuntimeSnapshot;
32
+ }
33
+ close() {
34
+ this.closed = true;
35
+ this.page = this.observedPage = this.runtimePage = undefined;
36
+ }
37
+ async initial() {
38
+ if (this.closed)
39
+ return undefined;
40
+ if (this.options.managedActive(this.session)) {
41
+ this.mode = 'RUNNING';
42
+ return { page: await this.runningWindow(), changed: false, handoff: false, snapshot: false };
43
+ }
44
+ // A fresh initial must not join an older observation's smaller or stale window.
45
+ if (this.refreshRead !== undefined &&
46
+ (this.page !== undefined || this.refreshMode === 'observation'))
47
+ await this.refreshRead.catch(() => undefined);
48
+ if (this.closed)
49
+ return undefined;
50
+ const joinedInitial = this.refreshRead !== undefined;
51
+ const update = await this.refresh('initial', INITIAL_TURN_LIMIT);
52
+ if (this.closed || update === undefined)
53
+ return undefined;
54
+ if (this.options.managedActive(this.session)) {
55
+ this.mode = 'RUNNING';
56
+ return { ...update, page: await this.runningWindow(update.page) };
57
+ }
58
+ return joinedInitial ? { ...update, changed: false, handoff: false, snapshot: false } : update;
59
+ }
60
+ refresh(mode = 'observation', limit = EMITTED_TURN_LIMIT) {
61
+ if (this.refreshRead !== undefined)
62
+ return this.refreshRead;
63
+ const read = this.performRefresh(mode, limit).finally(() => {
64
+ if (this.refreshRead === read) {
65
+ this.refreshRead = undefined;
66
+ this.refreshMode = undefined;
67
+ }
68
+ });
69
+ this.refreshRead = read;
70
+ this.refreshMode = mode;
71
+ return read;
72
+ }
73
+ async performRefresh(mode, limit) {
74
+ if (this.closed || this.options.suspended?.(this.session.id) === true)
75
+ return undefined;
76
+ if (mode === 'observation' && this.options.managedActive(this.session))
77
+ return undefined;
78
+ const historical = this.mode === 'HISTORY' && this.options.readHistoryPage !== undefined;
79
+ this.stage = 'refresh_activity';
80
+ await this.options.refreshActivity(this.session);
81
+ if (this.closed || this.options.suspended?.(this.session.id) === true)
82
+ return undefined;
83
+ this.stage = 'read_page';
84
+ const revision = this.eventRevision;
85
+ const page = await (historical
86
+ ? this.options.readHistoryPage(this.session, limit)
87
+ : this.options.readPage(this.session, limit));
88
+ if (this.closed)
89
+ return undefined;
90
+ if (revision !== this.eventRevision) {
91
+ if (mode !== 'initial')
92
+ return undefined;
93
+ const initial = convergeConversationWatchPage(this.page, this.observedPage, page, this.authority).page;
94
+ if (this.page === undefined && this.signature === undefined)
95
+ this.signature = JSON.stringify({
96
+ source: initial.source,
97
+ turns: initial.turns.slice(-EMITTED_TURN_LIMIT)
98
+ });
99
+ return { page: initial, changed: false, handoff: false, snapshot: false };
100
+ }
101
+ this.stage = 'converge_page';
102
+ const convergence = convergeConversationWatchPage(this.page, this.observedPage, page, this.authority);
103
+ this.observedPage = { ...page, turns: page.turns.slice(-EMITTED_TURN_LIMIT) };
104
+ this.authority = convergence.authority;
105
+ const accepted = {
106
+ ...convergence.page,
107
+ turns: convergence.page.turns.slice(-(EMITTED_TURN_LIMIT + 1))
108
+ };
109
+ const handoff = this.mode === 'CONVERGING' && !this.options.managedActive(this.session);
110
+ if (handoff) {
111
+ this.mode = 'HISTORY';
112
+ this.runtimePage = undefined;
113
+ this.runtimeRevision = undefined;
114
+ }
115
+ const signature = JSON.stringify({
116
+ source: accepted.source,
117
+ turns: accepted.turns.slice(-EMITTED_TURN_LIMIT)
118
+ });
119
+ const changed = signature !== this.signature;
120
+ this.signature = signature;
121
+ // Keep coverage metadata current even when the visible content did not change.
122
+ this.page = accepted;
123
+ return {
124
+ page: mode === 'initial' ? convergence.page : accepted,
125
+ changed,
126
+ handoff,
127
+ snapshot: handoff && (changed || accepted.source !== 'runtime')
128
+ };
129
+ }
130
+ /** Return work only for a domain transition; the Watch pauses delivery, not the Run. */
131
+ observe(event) {
132
+ this.eventRevision = event.sequence;
133
+ const activity = sessionActivity(event);
134
+ if (activity === 'RUNNING' && this.mode !== 'RUNNING') {
135
+ this.mode = 'RUNNING';
136
+ const generation = ++this.transitionGeneration;
137
+ return this.runtimeSnapshot(generation);
138
+ }
139
+ if (activity === 'IDLE' && this.mode === 'RUNNING') {
140
+ this.mode = 'CONVERGING';
141
+ const generation = ++this.transitionGeneration;
142
+ return this.convergeHistory(generation);
143
+ }
144
+ return undefined;
145
+ }
146
+ hydrate() {
147
+ if (this.closed ||
148
+ this.mode !== 'RUNNING' ||
149
+ this.page !== undefined ||
150
+ this.options.readHistoryPage === undefined)
151
+ return undefined;
152
+ if (this.historyRead !== undefined)
153
+ return undefined;
154
+ const read = this.hydrateHistory().finally(() => {
155
+ if (this.historyRead === read)
156
+ this.historyRead = undefined;
157
+ });
158
+ this.historyRead = read;
159
+ return read;
160
+ }
161
+ async hydrateHistory() {
162
+ const generation = this.transitionGeneration;
163
+ try {
164
+ const page = await this.options.readHistoryPage(this.session, INITIAL_TURN_LIMIT);
165
+ if (this.closed || this.mode !== 'RUNNING' || this.transitionGeneration !== generation)
166
+ return undefined;
167
+ this.page = page;
168
+ this.authority = page.source;
169
+ return await this.runtimeSnapshot(generation);
170
+ }
171
+ catch (error) {
172
+ if (this.closed || this.transitionGeneration !== generation)
173
+ return undefined;
174
+ throw error;
175
+ }
176
+ }
177
+ async convergeHistory(generation) {
178
+ await this.historyRead?.catch(() => undefined);
179
+ // Let the current event enter each lease's buffer before completing a handoff.
180
+ if (this.closed || this.mode !== 'CONVERGING' || this.transitionGeneration !== generation)
181
+ return undefined;
182
+ return this.refresh('observation', INITIAL_TURN_LIMIT);
183
+ }
184
+ async runningWindow(history = this.page) {
185
+ return composeConversationRuntimePage(history, await this.readRuntime());
186
+ }
187
+ async runtimeSnapshot(generation) {
188
+ this.pendingRuntimeSnapshot = true;
189
+ try {
190
+ const page = await this.runningWindow();
191
+ if (this.closed || this.mode !== 'RUNNING' || this.transitionGeneration !== generation)
192
+ return undefined;
193
+ return { page, changed: true, handoff: true, snapshot: true };
194
+ }
195
+ finally {
196
+ if (this.transitionGeneration === generation)
197
+ this.pendingRuntimeSnapshot = false;
198
+ }
199
+ }
200
+ readRuntime() {
201
+ if (this.runtimePage !== undefined && this.runtimeRevision === this.eventRevision)
202
+ return Promise.resolve(this.runtimePage);
203
+ if (this.runtimeRead?.revision === this.eventRevision)
204
+ return this.runtimeRead.read;
205
+ const revision = this.eventRevision;
206
+ const read = this.options
207
+ .readPage(this.session, INITIAL_TURN_LIMIT)
208
+ .then((page) => {
209
+ if (!this.closed && this.runtimeRead?.read === read) {
210
+ this.runtimePage = page;
211
+ this.runtimeRevision = revision;
212
+ }
213
+ return page;
214
+ })
215
+ .finally(() => {
216
+ if (this.runtimeRead?.read === read)
217
+ this.runtimeRead = undefined;
218
+ });
219
+ this.runtimeRead = { revision, read };
220
+ return read;
221
+ }
222
+ }
223
+ function sessionActivity(event) {
224
+ if (!isPlainRecord(event.payload))
225
+ return undefined;
226
+ if (event.category === 'run' && isPlainRecord(event.payload.run)) {
227
+ const status = event.payload.run.status;
228
+ return status === 'STARTING' || status === 'RUNNING' || status === 'CANCELLING'
229
+ ? 'RUNNING'
230
+ : undefined;
231
+ }
232
+ if (event.category !== 'session' || !isPlainRecord(event.payload.session))
233
+ return undefined;
234
+ const activity = event.payload.session.activity;
235
+ return typeof activity === 'string'
236
+ ? activity === 'MANAGED_ACTIVE'
237
+ ? 'RUNNING'
238
+ : 'IDLE'
239
+ : undefined;
240
+ }