@naisys/hub 3.0.0-beta.10

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,397 @@
1
+ import { HubEvents, MailArchiveRequestSchema, MailListRequestSchema, MailMarkReadRequestSchema, MailPeekRequestSchema, MailSearchRequestSchema, MailSendRequestSchema, MailUnreadRequestSchema, } from "@naisys/hub-protocol";
2
+ const MAIL_AUTOSTART_CHECK_INTERVAL_MS = 10_000;
3
+ /** Handles mail events from NAISYS instances */
4
+ export function createHubMailService(naisysServer, { hubDb }, logService, heartbeatService, sendMailService, agentService, costService, configService) {
5
+ /** Check for inactive users with unread mail and trigger auto-start for each */
6
+ async function checkPendingAutoStarts() {
7
+ try {
8
+ const config = configService.getConfig();
9
+ if (!config.success || !config.config?.autoStartAgentsOnMessage)
10
+ return;
11
+ const activeUserIds = heartbeatService.getActiveUserIds();
12
+ // Find distinct users with unread mail (exclude 'from' type - senders pre-mark as read)
13
+ const unreadRecipients = await hubDb.mail_recipients.findMany({
14
+ where: {
15
+ read_at: null,
16
+ type: { not: "from" },
17
+ user: { enabled: true, archived: false },
18
+ },
19
+ select: {
20
+ user_id: true,
21
+ },
22
+ distinct: ["user_id"],
23
+ });
24
+ const inactiveUserIds = unreadRecipients
25
+ .map((recipient) => recipient.user_id)
26
+ .filter((userId) => !activeUserIds.has(userId));
27
+ if (inactiveUserIds.length === 0)
28
+ return;
29
+ await costService.checkSpendLimits(inactiveUserIds);
30
+ for (const userId of inactiveUserIds) {
31
+ if (heartbeatService.getActiveUserIds().has(userId))
32
+ continue;
33
+ if (costService.isUserSpendSuspended(userId))
34
+ continue;
35
+ void agentService.tryStartAgent(userId);
36
+ }
37
+ }
38
+ catch (error) {
39
+ logService.error(`[Hub:Mail] Auto-start check failed: ${error}`);
40
+ }
41
+ }
42
+ // When a NAISYS host connects, check for pending unread mail and auto-start agents
43
+ naisysServer.registerEvent(HubEvents.CLIENT_CONNECTED, (_hostId, connection) => {
44
+ if (connection.getHostType() === "naisys") {
45
+ void checkPendingAutoStarts();
46
+ }
47
+ });
48
+ const pendingAutoStartInterval = setInterval(() => void checkPendingAutoStarts(), MAIL_AUTOSTART_CHECK_INTERVAL_MS);
49
+ // MAIL_SEND
50
+ naisysServer.registerEvent(HubEvents.MAIL_SEND, async (hostId, data, ack) => {
51
+ try {
52
+ const parsed = MailSendRequestSchema.parse(data);
53
+ await sendMailService.sendMail({
54
+ fromUserId: parsed.fromUserId,
55
+ recipientUserIds: parsed.toUserIds,
56
+ subject: parsed.subject,
57
+ body: parsed.body,
58
+ kind: parsed.kind,
59
+ hostId,
60
+ attachmentIds: parsed.attachmentIds,
61
+ });
62
+ ack({ success: true });
63
+ void checkPendingAutoStarts();
64
+ }
65
+ catch (error) {
66
+ logService.error(`[Hub:Mail] mail_send error from host ${hostId}: ${error}`);
67
+ ack({ success: false, error: String(error) });
68
+ }
69
+ });
70
+ // MAIL_LIST
71
+ naisysServer.registerEvent(HubEvents.MAIL_LIST, async (hostId, data, ack) => {
72
+ try {
73
+ const parsed = MailListRequestSchema.parse(data);
74
+ // Build ownership condition based on filter
75
+ let ownershipCondition;
76
+ if (parsed.withUserIds?.length) {
77
+ // Messages between exactly this group of participants
78
+ const allUserIds = [...new Set([parsed.userId, ...parsed.withUserIds])];
79
+ const users = await hubDb.users.findMany({
80
+ where: { id: { in: allUserIds } },
81
+ select: { username: true },
82
+ });
83
+ const participants = users
84
+ .map((u) => u.username)
85
+ .sort()
86
+ .join(",");
87
+ ownershipCondition = { participants };
88
+ }
89
+ else if (parsed.filter === "received") {
90
+ ownershipCondition = {
91
+ recipients: { some: { user_id: parsed.userId } },
92
+ };
93
+ }
94
+ else if (parsed.filter === "sent") {
95
+ ownershipCondition = { from_user_id: parsed.userId };
96
+ }
97
+ else {
98
+ ownershipCondition = {
99
+ OR: [
100
+ { from_user_id: parsed.userId },
101
+ { recipients: { some: { user_id: parsed.userId } } },
102
+ ],
103
+ };
104
+ }
105
+ const messages = await hubDb.mail_messages.findMany({
106
+ where: {
107
+ ...ownershipCondition,
108
+ kind: parsed.kind,
109
+ NOT: {
110
+ recipients: {
111
+ some: {
112
+ user_id: parsed.userId,
113
+ archived_at: { not: null },
114
+ },
115
+ },
116
+ },
117
+ },
118
+ include: {
119
+ from_user: { select: { username: true, title: true } },
120
+ recipients: {
121
+ include: { user: { select: { username: true } } },
122
+ },
123
+ mail_attachments: {
124
+ include: {
125
+ attachment: {
126
+ select: { public_id: true, filename: true, file_size: true },
127
+ },
128
+ },
129
+ },
130
+ },
131
+ orderBy: { created_at: "desc" },
132
+ skip: parsed.skip,
133
+ take: parsed.take ?? 20,
134
+ });
135
+ const messageData = messages.map((m) => {
136
+ const myRecipient = m.recipients.find((r) => r.user_id === parsed.userId && r.type !== "from");
137
+ const isUnread = m.from_user_id !== parsed.userId && !myRecipient?.read_at;
138
+ return {
139
+ id: m.id,
140
+ fromUsername: m.from_user.username,
141
+ fromTitle: m.from_user.title,
142
+ recipientUsernames: m.recipients
143
+ .filter((r) => r.type !== "from")
144
+ .map((r) => r.user.username),
145
+ subject: m.subject,
146
+ createdAt: m.created_at.toISOString(),
147
+ isUnread,
148
+ ...(parsed.kind === "chat" ? { body: m.body } : {}),
149
+ attachments: m.mail_attachments.length
150
+ ? m.mail_attachments.map((ma) => ({
151
+ id: ma.attachment.public_id,
152
+ filename: ma.attachment.filename,
153
+ fileSize: ma.attachment.file_size,
154
+ }))
155
+ : undefined,
156
+ };
157
+ });
158
+ ack({ success: true, messages: messageData });
159
+ }
160
+ catch (error) {
161
+ logService.error(`[Hub:Mail] mail_list error from host ${hostId}: ${error}`);
162
+ ack({ success: false, error: String(error) });
163
+ }
164
+ });
165
+ // MAIL_PEEK
166
+ naisysServer.registerEvent(HubEvents.MAIL_PEEK, async (hostId, data, ack) => {
167
+ try {
168
+ const parsed = MailPeekRequestSchema.parse(data);
169
+ const message = await hubDb.mail_messages.findUnique({
170
+ where: { id: parsed.messageId },
171
+ include: {
172
+ from_user: { select: { username: true, title: true } },
173
+ recipients: {
174
+ include: { user: { select: { username: true } } },
175
+ },
176
+ mail_attachments: {
177
+ include: {
178
+ attachment: {
179
+ select: { public_id: true, filename: true, file_size: true },
180
+ },
181
+ },
182
+ },
183
+ },
184
+ });
185
+ if (!message) {
186
+ ack({
187
+ success: false,
188
+ error: `Message ${parsed.messageId} not found`,
189
+ });
190
+ return;
191
+ }
192
+ ack({
193
+ success: true,
194
+ message: {
195
+ id: message.id,
196
+ subject: message.subject,
197
+ fromUsername: message.from_user.username,
198
+ fromTitle: message.from_user.title,
199
+ recipientUsernames: message.recipients
200
+ .filter((r) => r.type !== "from")
201
+ .map((r) => r.user.username),
202
+ createdAt: message.created_at.toISOString(),
203
+ body: message.body,
204
+ attachments: message.mail_attachments.length
205
+ ? message.mail_attachments.map((ma) => ({
206
+ id: ma.attachment.public_id,
207
+ filename: ma.attachment.filename,
208
+ fileSize: ma.attachment.file_size,
209
+ }))
210
+ : undefined,
211
+ },
212
+ });
213
+ }
214
+ catch (error) {
215
+ logService.error(`[Hub:Mail] mail_peek error from host ${hostId}: ${error}`);
216
+ ack({ success: false, error: String(error) });
217
+ }
218
+ });
219
+ // MAIL_MARK_READ
220
+ naisysServer.registerEvent(HubEvents.MAIL_MARK_READ, async (hostId, data, ack) => {
221
+ try {
222
+ const parsed = MailMarkReadRequestSchema.parse(data);
223
+ const result = await hubDb.mail_recipients.updateMany({
224
+ where: {
225
+ message_id: { in: parsed.messageIds },
226
+ user_id: parsed.userId,
227
+ read_at: null,
228
+ },
229
+ data: { read_at: new Date() },
230
+ });
231
+ ack({ success: true });
232
+ // Push read receipts to supervisor connections
233
+ if (result.count > 0) {
234
+ const messages = await hubDb.mail_messages.findMany({
235
+ where: { id: { in: parsed.messageIds } },
236
+ select: { participants: true },
237
+ });
238
+ // participants is like the room id, we broadcast to all rooms the read message ids
239
+ // It's ok if the specific message id is not in the room, the client will ignore it
240
+ const participants = [
241
+ ...new Set(messages.map((m) => m.participants)),
242
+ ];
243
+ const payload = {
244
+ messageIds: parsed.messageIds,
245
+ userId: parsed.userId,
246
+ kind: parsed.kind,
247
+ participants,
248
+ };
249
+ naisysServer.broadcastToSupervisors(HubEvents.MAIL_READ_PUSH, payload);
250
+ }
251
+ }
252
+ catch (error) {
253
+ logService.error(`[Hub:Mail] mail_mark_read error from host ${hostId}: ${error}`);
254
+ ack({ success: false, error: String(error) });
255
+ }
256
+ });
257
+ // MAIL_ARCHIVE
258
+ naisysServer.registerEvent(HubEvents.MAIL_ARCHIVE, async (hostId, data, ack) => {
259
+ try {
260
+ const parsed = MailArchiveRequestSchema.parse(data);
261
+ const archivedIds = [];
262
+ for (const messageId of parsed.messageIds) {
263
+ const message = await hubDb.mail_messages.findUnique({
264
+ where: { id: messageId },
265
+ });
266
+ if (!message) {
267
+ ack({
268
+ success: false,
269
+ error: `Message ${messageId} not found`,
270
+ });
271
+ return;
272
+ }
273
+ await hubDb.mail_recipients.updateMany({
274
+ where: { message_id: message.id, user_id: parsed.userId },
275
+ data: { archived_at: new Date() },
276
+ });
277
+ archivedIds.push(messageId);
278
+ }
279
+ ack({ success: true, archivedIds });
280
+ }
281
+ catch (error) {
282
+ logService.error(`[Hub:Mail] mail_archive error from host ${hostId}: ${error}`);
283
+ ack({ success: false, error: String(error) });
284
+ }
285
+ });
286
+ // MAIL_SEARCH
287
+ naisysServer.registerEvent(HubEvents.MAIL_SEARCH, async (hostId, data, ack) => {
288
+ try {
289
+ const parsed = MailSearchRequestSchema.parse(data);
290
+ const searchCondition = parsed.subjectOnly
291
+ ? { subject: { contains: parsed.terms } }
292
+ : {
293
+ OR: [
294
+ { subject: { contains: parsed.terms } },
295
+ { body: { contains: parsed.terms } },
296
+ ],
297
+ };
298
+ const archiveCondition = parsed.includeArchived
299
+ ? {}
300
+ : {
301
+ NOT: {
302
+ recipients: {
303
+ some: {
304
+ user_id: parsed.userId,
305
+ archived_at: { not: null },
306
+ },
307
+ },
308
+ },
309
+ };
310
+ const messages = await hubDb.mail_messages.findMany({
311
+ where: {
312
+ OR: [
313
+ { from_user_id: parsed.userId },
314
+ { recipients: { some: { user_id: parsed.userId } } },
315
+ ],
316
+ ...searchCondition,
317
+ ...archiveCondition,
318
+ },
319
+ include: {
320
+ from_user: { select: { username: true } },
321
+ },
322
+ orderBy: { created_at: "desc" },
323
+ take: 50,
324
+ });
325
+ const messageData = messages.map((m) => ({
326
+ id: m.id,
327
+ subject: m.subject,
328
+ fromUsername: m.from_user.username,
329
+ createdAt: m.created_at.toISOString(),
330
+ }));
331
+ ack({ success: true, messages: messageData });
332
+ }
333
+ catch (error) {
334
+ logService.error(`[Hub:Mail] mail_search error from host ${hostId}: ${error}`);
335
+ ack({ success: false, error: String(error) });
336
+ }
337
+ });
338
+ // MAIL_UNREAD
339
+ naisysServer.registerEvent(HubEvents.MAIL_UNREAD, async (hostId, data, ack) => {
340
+ try {
341
+ const parsed = MailUnreadRequestSchema.parse(data);
342
+ const messages = await hubDb.mail_messages.findMany({
343
+ where: {
344
+ kind: parsed.kind,
345
+ ...(parsed.afterId ? { id: { gt: parsed.afterId } } : {}),
346
+ recipients: {
347
+ some: { user_id: parsed.userId, read_at: null },
348
+ },
349
+ },
350
+ include: {
351
+ from_user: { select: { username: true, title: true } },
352
+ recipients: {
353
+ include: { user: { select: { username: true } } },
354
+ },
355
+ mail_attachments: {
356
+ include: {
357
+ attachment: {
358
+ select: { public_id: true, filename: true, file_size: true },
359
+ },
360
+ },
361
+ },
362
+ },
363
+ orderBy: { id: "asc" },
364
+ });
365
+ ack({
366
+ success: true,
367
+ messages: messages.map((m) => ({
368
+ id: m.id,
369
+ subject: m.subject,
370
+ fromUsername: m.from_user.username,
371
+ fromTitle: m.from_user.title,
372
+ recipientUsernames: m.recipients
373
+ .filter((r) => r.type !== "from")
374
+ .map((r) => r.user.username),
375
+ createdAt: m.created_at.toISOString(),
376
+ body: m.body,
377
+ attachments: m.mail_attachments.length
378
+ ? m.mail_attachments.map((ma) => ({
379
+ id: ma.attachment.public_id,
380
+ filename: ma.attachment.filename,
381
+ fileSize: ma.attachment.file_size,
382
+ }))
383
+ : undefined,
384
+ })),
385
+ });
386
+ }
387
+ catch (error) {
388
+ logService.error(`[Hub:Mail] mail_unread error from host ${hostId}: ${error}`);
389
+ ack({ success: false, error: String(error) });
390
+ }
391
+ });
392
+ function cleanup() {
393
+ clearInterval(pendingAutoStartInterval);
394
+ }
395
+ return { cleanup };
396
+ }
397
+ //# sourceMappingURL=hubMailService.js.map
@@ -0,0 +1,106 @@
1
+ import { builtInImageModels, builtInLlmModels, dbFieldsToImageModel, dbFieldsToLlmModel, imageModelToDbFields, llmModelToDbFields, } from "@naisys/common";
2
+ import { loadCustomModels } from "@naisys/common-node";
3
+ import { HubEvents } from "@naisys/hub-protocol";
4
+ /** Hub handler that seeds models on startup, pushes them on connect, and broadcasts on change */
5
+ export async function createHubModelsService(naisysServer, { hubDb }, logService) {
6
+ // Seed models table from built-in + YAML custom models (one-time, skips if non-empty)
7
+ await seedModels(hubDb, logService);
8
+ async function buildModelsPayload() {
9
+ const rows = (await hubDb.models.findMany());
10
+ const llmModels = rows
11
+ .filter((r) => r.type === "llm")
12
+ .map((r) => dbFieldsToLlmModel(r));
13
+ const imageModels = rows
14
+ .filter((r) => r.type === "image")
15
+ .map((r) => dbFieldsToImageModel(r));
16
+ return { success: true, llmModels, imageModels };
17
+ }
18
+ async function broadcastModels() {
19
+ try {
20
+ const payload = await buildModelsPayload();
21
+ logService.log(`[Hub:Models] Broadcasting ${payload.llmModels?.length ?? 0} LLM + ${payload.imageModels?.length ?? 0} image models to all clients`);
22
+ naisysServer.broadcastToAll(HubEvents.MODELS_UPDATED, payload);
23
+ }
24
+ catch (error) {
25
+ logService.error(`[Hub:Models] Error broadcasting models: ${error}`);
26
+ }
27
+ }
28
+ // Push models to newly connected clients
29
+ naisysServer.registerEvent(HubEvents.CLIENT_CONNECTED, async (hostId, connection) => {
30
+ try {
31
+ const payload = await buildModelsPayload();
32
+ logService.log(`[Hub:Models] Pushing ${payload.llmModels?.length ?? 0} LLM + ${payload.imageModels?.length ?? 0} image models to instance ${hostId}`);
33
+ connection.sendMessage(HubEvents.MODELS_UPDATED, payload);
34
+ }
35
+ catch (error) {
36
+ logService.error(`[Hub:Models] Error querying models for instance ${hostId}: ${error}`);
37
+ connection.sendMessage(HubEvents.MODELS_UPDATED, {
38
+ success: false,
39
+ error: String(error),
40
+ });
41
+ }
42
+ });
43
+ // Broadcast models to all clients when supervisor saves/deletes a model
44
+ naisysServer.registerEvent(HubEvents.MODELS_CHANGED, async () => {
45
+ await broadcastModels();
46
+ });
47
+ }
48
+ /** Seeds models table from built-in models + any YAML custom models.
49
+ * Built-in models are upserted on every startup unless the user has customized them.
50
+ * YAML custom models are only imported on first run (empty table). */
51
+ async function seedModels(hubDb, logService) {
52
+ const existingRows = (await hubDb.models.findMany());
53
+ const isFirstRun = existingRows.length === 0;
54
+ // Upsert built-in models that haven't been customized
55
+ const builtInFields = [
56
+ ...builtInLlmModels.map((m) => llmModelToDbFields(m, true, false)),
57
+ ...builtInImageModels.map((m) => imageModelToDbFields(m, true, false)),
58
+ ];
59
+ for (const fields of builtInFields) {
60
+ const existing = existingRows.find((r) => r.key === fields.key);
61
+ if (existing?.is_custom) {
62
+ // User has customized this built-in model, don't overwrite
63
+ continue;
64
+ }
65
+ if (existing) {
66
+ await hubDb.models.update({ where: { key: fields.key }, data: fields });
67
+ }
68
+ else {
69
+ await hubDb.models.create({ data: fields });
70
+ }
71
+ }
72
+ // Import YAML custom models only on first run (migration from file-based storage)
73
+ if (isFirstRun) {
74
+ const custom = loadCustomModels(process.env.NAISYS_FOLDER || "");
75
+ const customRows = [];
76
+ for (const m of custom.llmModels ?? []) {
77
+ const isBuiltin = builtInLlmModels.some((b) => b.key === m.key);
78
+ const fields = llmModelToDbFields(m, isBuiltin, true);
79
+ if (isBuiltin) {
80
+ // Override the built-in row we just inserted
81
+ await hubDb.models.update({ where: { key: m.key }, data: fields });
82
+ }
83
+ else {
84
+ customRows.push(fields);
85
+ }
86
+ }
87
+ for (const m of custom.imageModels ?? []) {
88
+ const isBuiltin = builtInImageModels.some((b) => b.key === m.key);
89
+ const fields = imageModelToDbFields(m, isBuiltin, true);
90
+ if (isBuiltin) {
91
+ await hubDb.models.update({ where: { key: m.key }, data: fields });
92
+ }
93
+ else {
94
+ customRows.push(fields);
95
+ }
96
+ }
97
+ if (customRows.length > 0) {
98
+ await hubDb.models.createMany({ data: customRows });
99
+ }
100
+ logService.log(`[Hub:Models] First run: imported ${(custom.llmModels?.length ?? 0) + (custom.imageModels?.length ?? 0)} custom models from YAML`);
101
+ }
102
+ else {
103
+ logService.log(`[Hub:Models] Models already seeded`);
104
+ }
105
+ }
106
+ //# sourceMappingURL=hubModelsService.js.map
@@ -0,0 +1,96 @@
1
+ import { HubEvents, SessionCreateRequestSchema, SessionIncrementRequestSchema, } from "@naisys/hub-protocol";
2
+ /** Handles session_create and session_increment requests from NAISYS instances */
3
+ export function createHubRunService(naisysServer, { hubDb }, logService) {
4
+ function pushSessionToSupervisors(session) {
5
+ naisysServer.broadcastToSupervisors(HubEvents.SESSION_PUSH, {
6
+ session: {
7
+ ...session,
8
+ latestLogId: 0,
9
+ totalLines: 0,
10
+ totalCost: 0,
11
+ },
12
+ });
13
+ }
14
+ naisysServer.registerEvent(HubEvents.SESSION_CREATE, async (hostId, data, ack) => {
15
+ try {
16
+ const parsed = SessionCreateRequestSchema.parse(data);
17
+ // Get the last run_id across all sessions
18
+ const lastRun = await hubDb.run_session.findFirst({
19
+ select: { run_id: true },
20
+ orderBy: { run_id: "desc" },
21
+ });
22
+ const newRunId = lastRun ? lastRun.run_id + 1 : 1;
23
+ const newSessionId = 1;
24
+ const now = new Date().toISOString();
25
+ await hubDb.run_session.create({
26
+ data: {
27
+ user_id: parsed.userId,
28
+ run_id: newRunId,
29
+ session_id: newSessionId,
30
+ host_id: hostId,
31
+ model_name: parsed.modelName,
32
+ created_at: now,
33
+ last_active: now,
34
+ },
35
+ });
36
+ ack({
37
+ success: true,
38
+ runId: newRunId,
39
+ sessionId: newSessionId,
40
+ });
41
+ pushSessionToSupervisors({
42
+ userId: parsed.userId,
43
+ runId: newRunId,
44
+ sessionId: newSessionId,
45
+ modelName: parsed.modelName,
46
+ createdAt: now,
47
+ lastActive: now,
48
+ });
49
+ }
50
+ catch (error) {
51
+ logService.error(`[Hub:Runs] session_create error for host ${hostId}: ${error}`);
52
+ ack({ success: false, error: String(error) });
53
+ }
54
+ });
55
+ naisysServer.registerEvent(HubEvents.SESSION_INCREMENT, async (hostId, data, ack) => {
56
+ try {
57
+ const parsed = SessionIncrementRequestSchema.parse(data);
58
+ // Get the max session_id for this user + run
59
+ const lastSession = await hubDb.run_session.findFirst({
60
+ select: { session_id: true },
61
+ where: {
62
+ user_id: parsed.userId,
63
+ run_id: parsed.runId,
64
+ },
65
+ orderBy: { session_id: "desc" },
66
+ });
67
+ const newSessionId = lastSession ? lastSession.session_id + 1 : 1;
68
+ const now = new Date().toISOString();
69
+ await hubDb.run_session.create({
70
+ data: {
71
+ user_id: parsed.userId,
72
+ run_id: parsed.runId,
73
+ session_id: newSessionId,
74
+ host_id: hostId,
75
+ model_name: parsed.modelName,
76
+ created_at: now,
77
+ last_active: now,
78
+ },
79
+ });
80
+ ack({ success: true, sessionId: newSessionId });
81
+ pushSessionToSupervisors({
82
+ userId: parsed.userId,
83
+ runId: parsed.runId,
84
+ sessionId: newSessionId,
85
+ modelName: parsed.modelName,
86
+ createdAt: now,
87
+ lastActive: now,
88
+ });
89
+ }
90
+ catch (error) {
91
+ logService.error(`[Hub:Runs] session_increment error for host ${hostId}: ${error}`);
92
+ ack({ success: false, error: String(error) });
93
+ }
94
+ });
95
+ }
96
+ //# sourceMappingURL=hubRunService.js.map