@naisys/supervisor 3.0.0-beta.6

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.
Files changed (57) hide show
  1. package/bin/naisys-supervisor +2 -0
  2. package/client-dist/android-chrome-192x192.png +0 -0
  3. package/client-dist/android-chrome-512x512.png +0 -0
  4. package/client-dist/apple-touch-icon.png +0 -0
  5. package/client-dist/assets/index-BBrK4ItN.js +177 -0
  6. package/client-dist/assets/index-CKg0vgt5.css +1 -0
  7. package/client-dist/assets/naisys-logo-CzoPnn5I.webp +0 -0
  8. package/client-dist/favicon-16x16.png +0 -0
  9. package/client-dist/favicon-32x32.png +0 -0
  10. package/client-dist/favicon.ico +0 -0
  11. package/client-dist/index.html +49 -0
  12. package/client-dist/site.webmanifest +22 -0
  13. package/dist/api-reference.js +52 -0
  14. package/dist/auth-middleware.js +116 -0
  15. package/dist/database/hubDb.js +26 -0
  16. package/dist/database/supervisorDb.js +18 -0
  17. package/dist/error-helpers.js +13 -0
  18. package/dist/hateoas.js +61 -0
  19. package/dist/logger.js +11 -0
  20. package/dist/route-helpers.js +7 -0
  21. package/dist/routes/admin.js +209 -0
  22. package/dist/routes/agentChat.js +194 -0
  23. package/dist/routes/agentConfig.js +265 -0
  24. package/dist/routes/agentLifecycle.js +350 -0
  25. package/dist/routes/agentMail.js +171 -0
  26. package/dist/routes/agentRuns.js +90 -0
  27. package/dist/routes/agents.js +236 -0
  28. package/dist/routes/api.js +52 -0
  29. package/dist/routes/attachments.js +18 -0
  30. package/dist/routes/auth.js +103 -0
  31. package/dist/routes/costs.js +51 -0
  32. package/dist/routes/hosts.js +296 -0
  33. package/dist/routes/models.js +152 -0
  34. package/dist/routes/root.js +56 -0
  35. package/dist/routes/schemas.js +31 -0
  36. package/dist/routes/status.js +20 -0
  37. package/dist/routes/users.js +420 -0
  38. package/dist/routes/variables.js +103 -0
  39. package/dist/schema-registry.js +23 -0
  40. package/dist/services/agentConfigService.js +182 -0
  41. package/dist/services/agentHostStatusService.js +178 -0
  42. package/dist/services/agentService.js +291 -0
  43. package/dist/services/attachmentProxyService.js +130 -0
  44. package/dist/services/browserSocketService.js +78 -0
  45. package/dist/services/chatService.js +201 -0
  46. package/dist/services/configExportService.js +61 -0
  47. package/dist/services/costsService.js +127 -0
  48. package/dist/services/hostService.js +156 -0
  49. package/dist/services/hubConnectionService.js +333 -0
  50. package/dist/services/logFileService.js +11 -0
  51. package/dist/services/mailService.js +154 -0
  52. package/dist/services/modelService.js +92 -0
  53. package/dist/services/runsService.js +164 -0
  54. package/dist/services/userService.js +147 -0
  55. package/dist/services/variableService.js +23 -0
  56. package/dist/supervisorServer.js +221 -0
  57. package/package.json +79 -0
@@ -0,0 +1,130 @@
1
+ import { createHash } from "crypto";
2
+ import https from "https";
3
+ import { hubDb } from "../database/hubDb.js";
4
+ import { getLogger } from "../logger.js";
5
+ import { getHubPinnedAgent, getHubUrl } from "./hubConnectionService.js";
6
+ /**
7
+ * Upload a file buffer to the hub's attachment endpoint.
8
+ * Returns the attachment ID from the hub.
9
+ */
10
+ export async function uploadToHub(fileBuffer, filename, uploadAsUserId, purpose = "mail") {
11
+ const hubUrl = getHubUrl();
12
+ if (!hubUrl) {
13
+ throw new Error("Hub URL not configured");
14
+ }
15
+ // Look up user's API key from the hub DB
16
+ const user = await hubDb.users.findUnique({
17
+ where: { id: uploadAsUserId },
18
+ select: { api_key: true },
19
+ });
20
+ if (!user?.api_key) {
21
+ throw new Error(`User ${uploadAsUserId} has no API key`);
22
+ }
23
+ // Compute SHA-256 of file buffer
24
+ const fileHash = createHash("sha256").update(fileBuffer).digest("hex");
25
+ const url = new URL("/attachments", hubUrl);
26
+ url.searchParams.set("filename", filename);
27
+ url.searchParams.set("filesize", String(fileBuffer.length));
28
+ url.searchParams.set("filehash", fileHash);
29
+ url.searchParams.set("purpose", purpose);
30
+ const response = await new Promise((resolve, reject) => {
31
+ const req = https.request(url, {
32
+ method: "POST",
33
+ agent: getHubPinnedAgent() ?? undefined,
34
+ headers: {
35
+ "Content-Length": fileBuffer.length,
36
+ Authorization: `Bearer ${user.api_key}`,
37
+ },
38
+ }, (res) => {
39
+ let body = "";
40
+ res.on("data", (chunk) => (body += chunk));
41
+ res.on("end", () => {
42
+ try {
43
+ const parsed = JSON.parse(body);
44
+ if (res.statusCode !== 200) {
45
+ reject(new Error(parsed.error ||
46
+ `Hub upload failed with status ${res.statusCode}`));
47
+ }
48
+ else {
49
+ resolve(parsed);
50
+ }
51
+ }
52
+ catch {
53
+ reject(new Error(`Invalid response from hub: ${body}`));
54
+ }
55
+ });
56
+ });
57
+ req.on("error", reject);
58
+ req.end(fileBuffer);
59
+ });
60
+ getLogger().info(`Uploaded attachment ${response.id} (${filename}) for user ${uploadAsUserId}`);
61
+ return response.id;
62
+ }
63
+ /**
64
+ * Proxy a download request from the client through to the hub's attachment endpoint.
65
+ * Streams the file directly from hub to client.
66
+ */
67
+ export async function proxyDownloadFromHub(publicId, reply) {
68
+ const hubUrl = getHubUrl();
69
+ if (!hubUrl) {
70
+ throw new Error("Hub URL not configured");
71
+ }
72
+ // Look up the attachment to get the uploader's user ID
73
+ const attachment = await hubDb.attachments.findUnique({
74
+ where: { public_id: publicId },
75
+ select: { uploaded_by: true },
76
+ });
77
+ if (!attachment) {
78
+ reply.code(404).send({ error: "Attachment not found" });
79
+ return;
80
+ }
81
+ // Look up the uploader's API key
82
+ const user = await hubDb.users.findUnique({
83
+ where: { id: attachment.uploaded_by },
84
+ select: { api_key: true },
85
+ });
86
+ if (!user?.api_key) {
87
+ reply.code(500).send({ error: "Cannot authenticate download to hub" });
88
+ return;
89
+ }
90
+ const url = new URL(`/attachments/${publicId}`, hubUrl);
91
+ return new Promise((resolve, reject) => {
92
+ const req = https.request(url, {
93
+ method: "GET",
94
+ agent: getHubPinnedAgent() ?? undefined,
95
+ headers: {
96
+ Authorization: `Bearer ${user.api_key}`,
97
+ },
98
+ }, (res) => {
99
+ if (res.statusCode !== 200) {
100
+ let body = "";
101
+ res.on("data", (chunk) => (body += chunk));
102
+ res.on("end", () => {
103
+ reply.code(res.statusCode || 500).send({
104
+ error: `Hub download failed: ${body}`,
105
+ });
106
+ resolve();
107
+ });
108
+ return;
109
+ }
110
+ // Forward headers from hub
111
+ if (res.headers["content-disposition"]) {
112
+ reply.header("content-disposition", res.headers["content-disposition"]);
113
+ }
114
+ if (res.headers["content-type"]) {
115
+ reply.header("content-type", res.headers["content-type"]);
116
+ }
117
+ if (res.headers["content-length"]) {
118
+ reply.header("content-length", res.headers["content-length"]);
119
+ }
120
+ reply.send(res);
121
+ resolve();
122
+ });
123
+ req.on("error", (err) => {
124
+ getLogger().error(err, "Error proxying attachment download");
125
+ reject(err);
126
+ });
127
+ req.end();
128
+ });
129
+ }
130
+ //# sourceMappingURL=attachmentProxyService.js.map
@@ -0,0 +1,78 @@
1
+ import { Server as SocketIOServer } from "socket.io";
2
+ import { extractBearerToken } from "@naisys/common-node";
3
+ import { resolveUserFromApiKey, resolveUserFromToken, } from "../auth-middleware.js";
4
+ import { isHubConnected } from "./hubConnectionService.js";
5
+ let io = null;
6
+ export function initBrowserSocket(httpServer, isProd) {
7
+ io = new SocketIOServer(httpServer, {
8
+ path: "/api/supervisor/ws",
9
+ cors: isProd
10
+ ? undefined
11
+ : { origin: ["http://localhost:3002"], credentials: true },
12
+ });
13
+ // Auth middleware: validate session cookie or API key on handshake
14
+ io.use(async (socket, next) => {
15
+ // Try cookie auth
16
+ const cookieHeader = socket.handshake.headers.cookie;
17
+ if (cookieHeader) {
18
+ const token = parseCookie(cookieHeader, "naisys_session");
19
+ if (token) {
20
+ const user = await resolveUserFromToken(token);
21
+ if (user) {
22
+ socket.data.user = user;
23
+ return next();
24
+ }
25
+ }
26
+ }
27
+ // Try API key auth
28
+ const apiKey = extractBearerToken(socket.handshake.headers.authorization);
29
+ if (apiKey) {
30
+ const user = await resolveUserFromApiKey(apiKey);
31
+ if (user) {
32
+ socket.data.user = user;
33
+ return next();
34
+ }
35
+ }
36
+ next(new Error("Authentication required"));
37
+ });
38
+ io.on("connection", (socket) => {
39
+ socket.on("subscribe", (data) => {
40
+ if (typeof data?.room === "string" && isRoomAllowed(data.room)) {
41
+ void socket.join(data.room);
42
+ // Send initial hub status when the client subscribes to the room
43
+ // (not on connect, because the client listener isn't ready yet)
44
+ if (data.room === "hub-status") {
45
+ socket.emit("hub-status", { hubConnected: isHubConnected() });
46
+ }
47
+ }
48
+ });
49
+ socket.on("unsubscribe", (data) => {
50
+ if (typeof data?.room === "string") {
51
+ void socket.leave(data.room);
52
+ }
53
+ });
54
+ });
55
+ }
56
+ export function getIO() {
57
+ if (!io)
58
+ throw new Error("Socket.IO not initialized");
59
+ return io;
60
+ }
61
+ function parseCookie(header, name) {
62
+ const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
63
+ return match ? decodeURIComponent(match[1]) : undefined;
64
+ }
65
+ const ALLOWED_ROOM_PREFIXES = [
66
+ "agent-status",
67
+ "host-status",
68
+ "hub-status",
69
+ "runs:",
70
+ "logs:",
71
+ "mail:",
72
+ "chat-conversations:",
73
+ "chat-messages:",
74
+ ];
75
+ function isRoomAllowed(room) {
76
+ return ALLOWED_ROOM_PREFIXES.some((prefix) => room === prefix || room.startsWith(prefix));
77
+ }
78
+ //# sourceMappingURL=browserSocketService.js.map
@@ -0,0 +1,201 @@
1
+ import { hubDb } from "../database/hubDb.js";
2
+ import { getLogger } from "../logger.js";
3
+ import { sendMailViaHub } from "./hubConnectionService.js";
4
+ /**
5
+ * Get chat conversations for a user, grouped by participants
6
+ */
7
+ export async function getConversations(userId, page = 1, count = 50) {
8
+ // Look up the current user's username for filtering
9
+ const currentUser = await hubDb.users.findUnique({
10
+ where: { id: userId },
11
+ select: { username: true },
12
+ });
13
+ const currentUsername = currentUser?.username;
14
+ // Get distinct conversations where this user is a participant
15
+ const messages = await hubDb.mail_messages.findMany({
16
+ where: {
17
+ kind: "chat",
18
+ participants: { not: "" },
19
+ OR: [
20
+ { from_user_id: userId },
21
+ { recipients: { some: { user_id: userId } } },
22
+ ],
23
+ },
24
+ orderBy: { created_at: "desc" },
25
+ select: {
26
+ participants: true,
27
+ body: true,
28
+ created_at: true,
29
+ from_user: { select: { username: true, title: true } },
30
+ recipients: {
31
+ where: { user_id: userId },
32
+ select: { archived_at: true },
33
+ },
34
+ },
35
+ });
36
+ // Look up titles for all participant usernames
37
+ const allUsernames = new Set();
38
+ for (const msg of messages) {
39
+ for (const name of msg.participants.split(",")) {
40
+ allUsernames.add(name);
41
+ }
42
+ }
43
+ const users = await hubDb.users.findMany({
44
+ where: { username: { in: [...allUsernames] } },
45
+ select: { username: true, title: true },
46
+ });
47
+ const titleMap = new Map(users.map((u) => [u.username, u.title]));
48
+ // Group by participants and take the latest message for each
49
+ const conversationMap = new Map();
50
+ for (const msg of messages) {
51
+ const key = msg.participants;
52
+ const existing = conversationMap.get(key);
53
+ if (!existing) {
54
+ conversationMap.set(key, {
55
+ lastMessage: msg.body,
56
+ lastMessageAt: msg.created_at,
57
+ lastMessageFrom: msg.from_user.username,
58
+ recipientRecords: [...msg.recipients],
59
+ });
60
+ }
61
+ else {
62
+ existing.recipientRecords.push(...msg.recipients);
63
+ }
64
+ }
65
+ // participants field already contains usernames, just split
66
+ const conversations = [];
67
+ for (const [participants, conv] of conversationMap) {
68
+ const names = participants.split(",");
69
+ // Exclude the current user from participant names
70
+ const participantNames = currentUsername
71
+ ? names.filter((n) => n !== currentUsername)
72
+ : names;
73
+ // Conversation is archived if there are recipient records and all are archived
74
+ const isArchived = conv.recipientRecords.length > 0 &&
75
+ conv.recipientRecords.every((r) => r.archived_at !== null);
76
+ conversations.push({
77
+ participants,
78
+ participantNames,
79
+ participantTitles: participantNames.map((n) => titleMap.get(n) ?? ""),
80
+ lastMessage: conv.lastMessage,
81
+ lastMessageAt: conv.lastMessageAt.toISOString(),
82
+ lastMessageFrom: conv.lastMessageFrom,
83
+ isArchived,
84
+ });
85
+ }
86
+ // Sort by latest message time (newest first)
87
+ conversations.sort((a, b) => new Date(b.lastMessageAt).getTime() - new Date(a.lastMessageAt).getTime());
88
+ const total = conversations.length;
89
+ const start = (page - 1) * count;
90
+ const paginated = conversations.slice(start, start + count);
91
+ return { conversations: paginated, total };
92
+ }
93
+ /**
94
+ * Get chat messages for a specific conversation
95
+ */
96
+ export async function getMessages(participants, updatedSince, page = 1, count = 50) {
97
+ const whereClause = {
98
+ kind: "chat",
99
+ participants,
100
+ };
101
+ if (updatedSince) {
102
+ whereClause.created_at = { gte: updatedSince };
103
+ }
104
+ // Only get total on initial fetch
105
+ const total = updatedSince
106
+ ? undefined
107
+ : await hubDb.mail_messages.count({ where: whereClause });
108
+ const dbMessages = await hubDb.mail_messages.findMany({
109
+ where: whereClause,
110
+ orderBy: { id: "desc" },
111
+ skip: (page - 1) * count,
112
+ take: count,
113
+ select: {
114
+ id: true,
115
+ from_user_id: true,
116
+ body: true,
117
+ created_at: true,
118
+ from_user: { select: { username: true, title: true } },
119
+ recipients: {
120
+ select: { user_id: true, read_at: true, type: true },
121
+ },
122
+ mail_attachments: {
123
+ include: {
124
+ attachment: {
125
+ select: { public_id: true, filename: true, file_size: true },
126
+ },
127
+ },
128
+ },
129
+ },
130
+ });
131
+ const messages = dbMessages.map((msg) => {
132
+ const readByIds = msg.recipients
133
+ .filter((r) => r.read_at !== null && r.type !== "from")
134
+ .map((r) => r.user_id);
135
+ return {
136
+ id: msg.id,
137
+ fromUserId: msg.from_user_id,
138
+ fromUsername: msg.from_user.username,
139
+ fromTitle: msg.from_user.title,
140
+ body: msg.body,
141
+ createdAt: msg.created_at.toISOString(),
142
+ attachments: msg.mail_attachments.length > 0
143
+ ? msg.mail_attachments.map((ma) => ({
144
+ id: ma.attachment.public_id,
145
+ filename: ma.attachment.filename,
146
+ fileSize: ma.attachment.file_size,
147
+ }))
148
+ : undefined,
149
+ readBy: readByIds.length > 0 ? readByIds : undefined,
150
+ };
151
+ });
152
+ return {
153
+ messages,
154
+ total,
155
+ timestamp: new Date().toISOString(),
156
+ };
157
+ }
158
+ /**
159
+ * Archive all chat messages where the user is a recipient
160
+ */
161
+ export async function archiveAllChatMessages(userId) {
162
+ const result = await hubDb.mail_recipients.updateMany({
163
+ where: {
164
+ user_id: userId,
165
+ archived_at: null,
166
+ message: {
167
+ kind: "chat",
168
+ },
169
+ },
170
+ data: {
171
+ archived_at: new Date(),
172
+ },
173
+ });
174
+ return result.count;
175
+ }
176
+ /**
177
+ * Send a chat message via the hub
178
+ */
179
+ export async function sendChatMessage(fromId, toIds, message, attachmentIds) {
180
+ try {
181
+ const cleanMessage = message.replace(/\\n/g, "\n");
182
+ const response = await sendMailViaHub(fromId, toIds, "", cleanMessage, "chat", attachmentIds);
183
+ if (response.success) {
184
+ return { success: true, message: "Chat message sent" };
185
+ }
186
+ else {
187
+ return {
188
+ success: false,
189
+ message: response.error || "Failed to send chat message",
190
+ };
191
+ }
192
+ }
193
+ catch (error) {
194
+ getLogger().error(error, "Error sending chat message");
195
+ return {
196
+ success: false,
197
+ message: error instanceof Error ? error.message : "Failed to send chat message",
198
+ };
199
+ }
200
+ }
201
+ //# sourceMappingURL=chatService.js.map
@@ -0,0 +1,61 @@
1
+ import { dbFieldsToImageModel, dbFieldsToLlmModel, } from "@naisys/common";
2
+ import yaml from "js-yaml";
3
+ function toKebabCase(str) {
4
+ return str
5
+ .trim()
6
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
7
+ .replace(/[\s_]+/g, "-")
8
+ .replace(/[^a-z0-9-]/gi, "")
9
+ .toLowerCase();
10
+ }
11
+ function agentFileName(user) {
12
+ return toKebabCase(user.title || user.username);
13
+ }
14
+ export function buildExportFiles(users, variables, modelRows) {
15
+ const files = [];
16
+ // --- Agents ---
17
+ const activeUsers = users.filter((u) => u.username !== "admin" && !u.archived);
18
+ const userById = new Map(activeUsers.map((u) => [u.id, u]));
19
+ // Walk up ancestor chain to build full directory path
20
+ function agentDirPath(user) {
21
+ const segments = [];
22
+ let current = user.lead_user_id
23
+ ? userById.get(user.lead_user_id)
24
+ : undefined;
25
+ while (current) {
26
+ segments.unshift(agentFileName(current));
27
+ current = current.lead_user_id
28
+ ? userById.get(current.lead_user_id)
29
+ : undefined;
30
+ }
31
+ return ["agents", ...segments].join("/");
32
+ }
33
+ for (const user of activeUsers) {
34
+ const filePath = `${agentDirPath(user)}/${agentFileName(user)}.yaml`;
35
+ const configObj = JSON.parse(user.config);
36
+ const configYaml = yaml.dump(configObj, { lineWidth: -1 });
37
+ files.push({ path: filePath, content: configYaml });
38
+ }
39
+ // --- Variables ---
40
+ if (variables.length > 0) {
41
+ const envContent = variables.map((v) => `${v.key}=${v.value}`).join("\n");
42
+ files.push({ path: ".env", content: envContent + "\n" });
43
+ }
44
+ // --- Custom models ---
45
+ const customModels = modelRows.filter((r) => r.is_custom);
46
+ if (customModels.length > 0) {
47
+ const llmRows = customModels.filter((r) => r.type === "llm");
48
+ const imageRows = customModels.filter((r) => r.type === "image");
49
+ const output = {};
50
+ if (llmRows.length > 0) {
51
+ output.llmModels = llmRows.map(dbFieldsToLlmModel);
52
+ }
53
+ if (imageRows.length > 0) {
54
+ output.imageModels = imageRows.map(dbFieldsToImageModel);
55
+ }
56
+ const yamlStr = yaml.dump(output, { lineWidth: -1 });
57
+ files.push({ path: "custom-models.yaml", content: yamlStr });
58
+ }
59
+ return files;
60
+ }
61
+ //# sourceMappingURL=configExportService.js.map
@@ -0,0 +1,127 @@
1
+ import { hubDb } from "../database/hubDb.js";
2
+ export async function getSpendLimitSettings() {
3
+ const vars = await hubDb.variables.findMany({
4
+ where: { key: { in: ["SPEND_LIMIT_DOLLARS", "SPEND_LIMIT_HOURS"] } },
5
+ });
6
+ const dollarsVar = vars.find((v) => v.key === "SPEND_LIMIT_DOLLARS");
7
+ const hoursVar = vars.find((v) => v.key === "SPEND_LIMIT_HOURS");
8
+ return {
9
+ spendLimitDollars: dollarsVar ? parseFloat(dollarsVar.value) || null : null,
10
+ spendLimitHours: hoursVar ? parseFloat(hoursVar.value) || null : null,
11
+ };
12
+ }
13
+ /** Find the lead user and all subordinates recursively */
14
+ export async function findUserIdsForLead(leadUsername) {
15
+ const leadUser = await hubDb.users.findUnique({
16
+ where: { username: leadUsername },
17
+ select: { id: true },
18
+ });
19
+ if (!leadUser)
20
+ return [];
21
+ const allUsers = await hubDb.users.findMany({
22
+ select: { id: true, lead_user_id: true },
23
+ });
24
+ const result = [leadUser.id];
25
+ function collect(parentId) {
26
+ for (const user of allUsers) {
27
+ if (user.lead_user_id === parentId) {
28
+ result.push(user.id);
29
+ collect(user.id);
30
+ }
31
+ }
32
+ }
33
+ collect(leadUser.id);
34
+ return result;
35
+ }
36
+ export async function getCostHistogram(start, end, bucketHours, userIds) {
37
+ const where = {
38
+ created_at: { gte: start, lte: end },
39
+ };
40
+ if (userIds) {
41
+ where.user_id = { in: userIds };
42
+ }
43
+ const costs = await hubDb.costs.findMany({
44
+ where,
45
+ select: {
46
+ cost: true,
47
+ created_at: true,
48
+ user_id: true,
49
+ },
50
+ orderBy: { created_at: "asc" },
51
+ });
52
+ // Collect unique user IDs and resolve usernames
53
+ const costUserIds = new Set(costs.map((c) => c.user_id));
54
+ const users = costUserIds.size > 0
55
+ ? await hubDb.users.findMany({
56
+ where: { id: { in: Array.from(costUserIds) } },
57
+ select: { id: true, username: true },
58
+ })
59
+ : [];
60
+ const userMap = new Map(users.map((u) => [u.id, u.username]));
61
+ // Build buckets
62
+ const bucketMs = bucketHours * 60 * 60 * 1000;
63
+ const startMs = start.getTime();
64
+ const endMs = end.getTime();
65
+ const buckets = [];
66
+ for (let bucketStart = startMs; bucketStart < endMs; bucketStart += bucketMs) {
67
+ const bucketEnd = Math.min(bucketStart + bucketMs, endMs);
68
+ buckets.push({
69
+ start: new Date(bucketStart).toISOString(),
70
+ end: new Date(bucketEnd).toISOString(),
71
+ cost: 0,
72
+ byAgent: {},
73
+ });
74
+ }
75
+ // Fill buckets with costs
76
+ for (const cost of costs) {
77
+ const costMs = cost.created_at.getTime();
78
+ const bucketIndex = Math.floor((costMs - startMs) / bucketMs);
79
+ if (bucketIndex >= 0 && bucketIndex < buckets.length) {
80
+ const amount = cost.cost ?? 0;
81
+ buckets[bucketIndex].cost += amount;
82
+ const username = userMap.get(cost.user_id) ?? `user-${cost.user_id}`;
83
+ buckets[bucketIndex].byAgent[username] =
84
+ (buckets[bucketIndex].byAgent[username] ?? 0) + amount;
85
+ }
86
+ }
87
+ return buckets;
88
+ }
89
+ export async function getCostsByAgent(start, end, userIds) {
90
+ const where = {
91
+ created_at: { gte: start, lte: end },
92
+ };
93
+ if (userIds) {
94
+ where.user_id = { in: userIds };
95
+ }
96
+ const costs = await hubDb.costs.findMany({
97
+ where,
98
+ select: {
99
+ cost: true,
100
+ user_id: true,
101
+ },
102
+ });
103
+ // Aggregate by user_id
104
+ const byUserId = new Map();
105
+ for (const c of costs) {
106
+ byUserId.set(c.user_id, (byUserId.get(c.user_id) ?? 0) + (c.cost ?? 0));
107
+ }
108
+ if (byUserId.size === 0)
109
+ return [];
110
+ // Look up usernames
111
+ const users = await hubDb.users.findMany({
112
+ where: { id: { in: Array.from(byUserId.keys()) } },
113
+ select: { id: true, username: true, title: true },
114
+ });
115
+ const userMap = new Map(users.map((u) => [u.id, { username: u.username, title: u.title }]));
116
+ return Array.from(byUserId.entries())
117
+ .map(([userId, cost]) => {
118
+ const user = userMap.get(userId);
119
+ return {
120
+ username: user?.username ?? `user-${userId}`,
121
+ title: user?.title ?? "",
122
+ cost: Math.round(cost * 100) / 100,
123
+ };
124
+ })
125
+ .sort((a, b) => b.cost - a.cost);
126
+ }
127
+ //# sourceMappingURL=costsService.js.map