@naisys/hub 3.0.0-beta.37 → 3.0.0-beta.39
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/dist/handlers/hubAgentService.js +98 -57
- package/dist/handlers/hubAttachmentService.js +12 -30
- package/dist/handlers/hubConfigService.js +1 -0
- package/dist/handlers/hubCostService.js +20 -11
- package/dist/handlers/hubCostService.test.js +263 -0
- package/dist/handlers/hubHeartbeatService.js +29 -16
- package/dist/handlers/hubHostService.js +3 -0
- package/dist/handlers/hubLogService.js +8 -1
- package/dist/handlers/hubRunService.js +39 -10
- package/dist/handlers/hubUserService.js +0 -2
- package/dist/naisysHub.js +34 -11
- package/dist/services/agentRegistrar.js +1 -2
- package/dist/services/naisysServer.js +7 -2
- package/npm-shrinkwrap.json +23 -23
- package/package.json +9 -7
|
@@ -1,6 +1,29 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { hashToken } from "@naisys/common-node";
|
|
2
|
+
import { AgentPeekRequestSchema, AgentRunCommandRequestSchema, AgentRunPauseRequestSchema, AgentStartInboundSchema, AgentStopRequestSchema, HubEvents, } from "@naisys/hub-protocol";
|
|
3
|
+
import { randomBytes } from "crypto";
|
|
2
4
|
/** Handles agent_start requests by routing them to the least-loaded eligible host */
|
|
3
5
|
export function createHubAgentService(naisysServer, { hubDb }, logService, heartbeatService, sendMailService, hostRegistrar) {
|
|
6
|
+
/**
|
|
7
|
+
* Mint a fresh runtime API key for a user, rotating any prior key. Plaintext
|
|
8
|
+
* is returned only here and travels once over the AGENT_START message; the
|
|
9
|
+
* DB only stores the hash. Keys persist across hub restarts/crashes/updates;
|
|
10
|
+
* revocation lives on the user disable/archive/delete paths and on graceful
|
|
11
|
+
* AGENT_STOP.
|
|
12
|
+
*/
|
|
13
|
+
async function issueRuntimeApiKey(userId) {
|
|
14
|
+
const token = randomBytes(32).toString("hex");
|
|
15
|
+
await hubDb.users.update({
|
|
16
|
+
where: { id: userId },
|
|
17
|
+
data: { api_key_hash: hashToken(token) },
|
|
18
|
+
});
|
|
19
|
+
return token;
|
|
20
|
+
}
|
|
21
|
+
async function revokeRuntimeApiKey(userId) {
|
|
22
|
+
await hubDb.users.update({
|
|
23
|
+
where: { id: userId },
|
|
24
|
+
data: { api_key_hash: null },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
4
27
|
/** Find the least-loaded eligible host for a given user */
|
|
5
28
|
async function findBestHost(startUserId) {
|
|
6
29
|
// Look up which hosts this user is assigned to
|
|
@@ -52,30 +75,61 @@ export function createHubAgentService(naisysServer, { hubDb }, logService, heart
|
|
|
52
75
|
});
|
|
53
76
|
return !!user?.enabled && !user?.archived;
|
|
54
77
|
}
|
|
78
|
+
/** Run the start preconditions and pick a host. */
|
|
79
|
+
async function decideStartAgent(userId) {
|
|
80
|
+
if (!(await isAgentEnabled(userId))) {
|
|
81
|
+
return { kind: "fail", error: `Agent ${userId} is disabled` };
|
|
82
|
+
}
|
|
83
|
+
if (heartbeatService.findHostsForAgent(userId).length > 0) {
|
|
84
|
+
return { kind: "fail", error: `Agent ${userId} is already running` };
|
|
85
|
+
}
|
|
86
|
+
const bestHostId = await findBestHost(userId);
|
|
87
|
+
if (bestHostId === null) {
|
|
88
|
+
return {
|
|
89
|
+
kind: "fail",
|
|
90
|
+
error: `No eligible hosts are online for user ${userId}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { kind: "go", bestHostId };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Issue a runtime key and send AGENT_START. Stranded keys from a failed
|
|
97
|
+
* send or failed response stay in the DB until the next AGENT_START for
|
|
98
|
+
* that user rotates them, or the user is disabled/archived/deleted — the
|
|
99
|
+
* agent process that would have used the key never started, so there's
|
|
100
|
+
* nobody to authenticate with it in the meantime.
|
|
101
|
+
*/
|
|
102
|
+
async function dispatchAgentStart(args) {
|
|
103
|
+
const { bestHostId, payload, onResponse } = args;
|
|
104
|
+
const startUserId = payload.startUserId;
|
|
105
|
+
const runtimeApiKey = await issueRuntimeApiKey(startUserId);
|
|
106
|
+
const sent = naisysServer.sendMessage(bestHostId, HubEvents.AGENT_START, { ...payload, runtimeApiKey }, (response) => {
|
|
107
|
+
if (response.success) {
|
|
108
|
+
heartbeatService.addStartedAgent(bestHostId, startUserId);
|
|
109
|
+
}
|
|
110
|
+
onResponse(response);
|
|
111
|
+
});
|
|
112
|
+
return { sent };
|
|
113
|
+
}
|
|
55
114
|
/** Try to start an agent on the best available host (fire-and-forget) */
|
|
56
115
|
async function tryStartAgent(startUserId) {
|
|
57
116
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
const bestHostId = await findBestHost(startUserId);
|
|
63
|
-
if (bestHostId === null) {
|
|
64
|
-
logService.log(`[Hub:Agents] Auto-start: no eligible host for user ${startUserId}`);
|
|
117
|
+
const decision = await decideStartAgent(startUserId);
|
|
118
|
+
if (decision.kind === "fail") {
|
|
119
|
+
logService.log(`[Hub:Agents] Auto-start: ${decision.error}`);
|
|
65
120
|
return false;
|
|
66
121
|
}
|
|
67
|
-
const sent =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
122
|
+
const { sent } = await dispatchAgentStart({
|
|
123
|
+
bestHostId: decision.bestHostId,
|
|
124
|
+
payload: { startUserId },
|
|
125
|
+
onResponse: (response) => {
|
|
126
|
+
if (!response.success) {
|
|
127
|
+
logService.error(`[Hub:Agents] Auto-start failed for user ${startUserId}: ${response.error}`);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
76
130
|
});
|
|
77
131
|
if (sent) {
|
|
78
|
-
logService.log(`[Hub:Agents] Auto-start: sent start for user ${startUserId} to host ${bestHostId}`);
|
|
132
|
+
logService.log(`[Hub:Agents] Auto-start: sent start for user ${startUserId} to host ${decision.bestHostId}`);
|
|
79
133
|
}
|
|
80
134
|
return sent;
|
|
81
135
|
}
|
|
@@ -86,51 +140,33 @@ export function createHubAgentService(naisysServer, { hubDb }, logService, heart
|
|
|
86
140
|
}
|
|
87
141
|
naisysServer.registerEvent(HubEvents.AGENT_START, async (hostId, data, ack) => {
|
|
88
142
|
try {
|
|
89
|
-
const parsed =
|
|
90
|
-
const
|
|
91
|
-
if (
|
|
92
|
-
ack({
|
|
93
|
-
success: false,
|
|
94
|
-
error: `Agent ${parsed.startUserId} is disabled`,
|
|
95
|
-
});
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
const bestHostId = await findBestHost(parsed.startUserId);
|
|
99
|
-
if (bestHostId === null) {
|
|
100
|
-
ack({
|
|
101
|
-
success: false,
|
|
102
|
-
error: `No eligible hosts are online for user ${parsed.startUserId}`,
|
|
103
|
-
});
|
|
143
|
+
const parsed = AgentStartInboundSchema.parse(data);
|
|
144
|
+
const decision = await decideStartAgent(parsed.startUserId);
|
|
145
|
+
if (decision.kind === "fail") {
|
|
146
|
+
ack({ success: false, error: decision.error });
|
|
104
147
|
return;
|
|
105
148
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
// Reverse-ack with the response from the host (including success status and any error message) back to the original requester
|
|
123
|
-
ack(response);
|
|
124
|
-
// Send task description mail after successful start to avoid
|
|
125
|
-
// orphaned mails from failed start attempts
|
|
126
|
-
if (response.success && parsed.taskDescription) {
|
|
127
|
-
void sendTaskMail(parsed.startUserId, requesterUserId, parsed.taskDescription);
|
|
128
|
-
}
|
|
149
|
+
const { sent } = await dispatchAgentStart({
|
|
150
|
+
bestHostId: decision.bestHostId,
|
|
151
|
+
payload: {
|
|
152
|
+
startUserId: parsed.startUserId,
|
|
153
|
+
taskDescription: parsed.taskDescription,
|
|
154
|
+
sourceHostId: hostId,
|
|
155
|
+
},
|
|
156
|
+
onResponse: (response) => {
|
|
157
|
+
// Reverse-ack with the response from the host (including success status and any error message) back to the original requester
|
|
158
|
+
ack(response);
|
|
159
|
+
// Send task description mail after successful start to avoid
|
|
160
|
+
// orphaned mails from failed start attempts
|
|
161
|
+
if (response.success && parsed.taskDescription) {
|
|
162
|
+
void sendTaskMail(parsed.startUserId, parsed.requesterUserId, parsed.taskDescription);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
129
165
|
});
|
|
130
166
|
if (!sent) {
|
|
131
167
|
ack({
|
|
132
168
|
success: false,
|
|
133
|
-
error: `Failed to send to host ${bestHostId}`,
|
|
169
|
+
error: `Failed to send to host ${decision.bestHostId}`,
|
|
134
170
|
});
|
|
135
171
|
}
|
|
136
172
|
}
|
|
@@ -176,6 +212,9 @@ export function createHubAgentService(naisysServer, { hubDb }, logService, heart
|
|
|
176
212
|
}, (response) => {
|
|
177
213
|
if (response.success) {
|
|
178
214
|
heartbeatService.removeStoppedAgent(targetHostId, parsed.userId);
|
|
215
|
+
revokeRuntimeApiKey(parsed.userId).catch((err) => {
|
|
216
|
+
logService.error(`[Hub:Agents] Failed to revoke runtime key for user ${parsed.userId} on stop: ${err}`);
|
|
217
|
+
});
|
|
179
218
|
}
|
|
180
219
|
// Ack with the first response
|
|
181
220
|
if (!acked) {
|
|
@@ -217,6 +256,7 @@ export function createHubAgentService(naisysServer, { hubDb }, logService, heart
|
|
|
217
256
|
const sent = naisysServer.sendMessage(targetHostId, event, {
|
|
218
257
|
userId: parsed.userId,
|
|
219
258
|
runId: parsed.runId,
|
|
259
|
+
subagentId: parsed.subagentId,
|
|
220
260
|
sessionId: parsed.sessionId,
|
|
221
261
|
sourceHostId: hostId,
|
|
222
262
|
}, (response) => {
|
|
@@ -261,6 +301,7 @@ export function createHubAgentService(naisysServer, { hubDb }, logService, heart
|
|
|
261
301
|
const sent = naisysServer.sendMessage(targetHostId, HubEvents.AGENT_RUN_COMMAND, {
|
|
262
302
|
userId: parsed.userId,
|
|
263
303
|
runId: parsed.runId,
|
|
304
|
+
subagentId: parsed.subagentId,
|
|
264
305
|
sessionId: parsed.sessionId,
|
|
265
306
|
command: parsed.command,
|
|
266
307
|
sourceHostId: hostId,
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { createReadStream, createWriteStream, existsSync, mkdirSync, renameSync, statSync, unlinkSync, } from "fs";
|
|
1
|
+
import { extractBearerToken, generateAttachmentPublicId, getHubAttachmentPath, hashToken, MAX_HUB_ATTACHMENT_SIZE, sendAttachmentResponse, } from "@naisys/common-node";
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import { createWriteStream, existsSync, mkdirSync, renameSync, unlinkSync, } from "fs";
|
|
5
4
|
import { join } from "path";
|
|
6
5
|
import { pipeline, Writable } from "stream";
|
|
7
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
8
6
|
/**
|
|
9
7
|
* HTTP attachment upload/download service.
|
|
10
8
|
* Registers Fastify routes for `/hub/attachments` paths.
|
|
@@ -12,7 +10,9 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
|
12
10
|
export function createHubAttachmentService(fastify, { hubDb }, logService) {
|
|
13
11
|
const naisysFolder = process.env.NAISYS_FOLDER || "";
|
|
14
12
|
async function resolveUserByApiKey(apiKey) {
|
|
15
|
-
const user = await hubDb.users.findUnique({
|
|
13
|
+
const user = await hubDb.users.findUnique({
|
|
14
|
+
where: { api_key_hash: hashToken(apiKey) },
|
|
15
|
+
});
|
|
16
16
|
return user?.id ?? null;
|
|
17
17
|
}
|
|
18
18
|
// Upload route — encapsulated so the raw content-type parser doesn't leak
|
|
@@ -49,9 +49,9 @@ export function createHubAttachmentService(fastify, { hubDb }, logService) {
|
|
|
49
49
|
if (isNaN(fileSize) || fileSize <= 0) {
|
|
50
50
|
return reply.code(400).send({ error: "Invalid filesize" });
|
|
51
51
|
}
|
|
52
|
-
if (fileSize >
|
|
52
|
+
if (fileSize > MAX_HUB_ATTACHMENT_SIZE) {
|
|
53
53
|
return reply.code(413).send({
|
|
54
|
-
error: `File too large. Max size: ${
|
|
54
|
+
error: `File too large. Max size: ${MAX_HUB_ATTACHMENT_SIZE} bytes`,
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
57
|
const userId = await resolveUserByApiKey(apiKey);
|
|
@@ -71,7 +71,7 @@ export function createHubAttachmentService(fastify, { hubDb }, logService) {
|
|
|
71
71
|
const sizeChecker = new Writable({
|
|
72
72
|
write(chunk, _encoding, callback) {
|
|
73
73
|
bytesWritten += chunk.length;
|
|
74
|
-
if (bytesWritten >
|
|
74
|
+
if (bytesWritten > MAX_HUB_ATTACHMENT_SIZE) {
|
|
75
75
|
callback(new Error("File exceeds size limit"));
|
|
76
76
|
return;
|
|
77
77
|
}
|
|
@@ -116,10 +116,8 @@ export function createHubAttachmentService(fastify, { hubDb }, logService) {
|
|
|
116
116
|
error: `Hash mismatch. Expected: ${fileHash}, got: ${computedHash}`,
|
|
117
117
|
});
|
|
118
118
|
}
|
|
119
|
-
|
|
120
|
-
const storageDir = join(naisysFolder, "attachments", "hub", computedHash.slice(0, 2), computedHash.slice(2, 4));
|
|
119
|
+
const { storageDir, storagePath } = getHubAttachmentPath(naisysFolder, computedHash);
|
|
121
120
|
mkdirSync(storageDir, { recursive: true });
|
|
122
|
-
const storagePath = join(storageDir, computedHash);
|
|
123
121
|
if (existsSync(storagePath)) {
|
|
124
122
|
// Dedup: identical file already on disk, discard temp
|
|
125
123
|
try {
|
|
@@ -132,10 +130,9 @@ export function createHubAttachmentService(fastify, { hubDb }, logService) {
|
|
|
132
130
|
else {
|
|
133
131
|
renameSync(tmpPath, storagePath);
|
|
134
132
|
}
|
|
135
|
-
// Create DB record
|
|
136
133
|
const record = await hubDb.attachments.create({
|
|
137
134
|
data: {
|
|
138
|
-
public_id:
|
|
135
|
+
public_id: generateAttachmentPublicId(),
|
|
139
136
|
filepath: storagePath,
|
|
140
137
|
filename,
|
|
141
138
|
file_size: bytesWritten,
|
|
@@ -174,22 +171,7 @@ export function createHubAttachmentService(fastify, { hubDb }, logService) {
|
|
|
174
171
|
if (!attachment) {
|
|
175
172
|
return reply.code(404).send({ error: "Attachment not found" });
|
|
176
173
|
}
|
|
177
|
-
|
|
178
|
-
return reply
|
|
179
|
-
.code(404)
|
|
180
|
-
.send({ error: "Attachment file missing from disk" });
|
|
181
|
-
}
|
|
182
|
-
const stat = statSync(attachment.filepath);
|
|
183
|
-
const contentType = mimeFromFilename(attachment.filename);
|
|
184
|
-
const disposition = contentType.startsWith("image/")
|
|
185
|
-
? "inline"
|
|
186
|
-
: "attachment";
|
|
187
|
-
reply
|
|
188
|
-
.header("Content-Type", contentType)
|
|
189
|
-
.header("Content-Disposition", `${disposition}; filename="${attachment.filename.replace(/"/g, '\\"')}"`)
|
|
190
|
-
.header("Content-Length", stat.size);
|
|
191
|
-
const readStream = createReadStream(attachment.filepath);
|
|
192
|
-
return reply.send(readStream);
|
|
174
|
+
return sendAttachmentResponse(reply, attachment.filepath, attachment.filename);
|
|
193
175
|
}
|
|
194
176
|
fastify.get("/hub/attachments/:publicId/:filename", async (request, reply) => {
|
|
195
177
|
try {
|
|
@@ -40,6 +40,7 @@ export async function createHubConfigService(naisysServer, { hubDb }, logService
|
|
|
40
40
|
{ key: "SPEND_LIMIT_DOLLARS" },
|
|
41
41
|
{ key: "SPEND_LIMIT_HOURS" },
|
|
42
42
|
{ key: "TARGET_VERSION" },
|
|
43
|
+
{ key: "MAIL_ENABLED" },
|
|
43
44
|
]);
|
|
44
45
|
/** Read variables from DB and build a ConfigResponse */
|
|
45
46
|
async function buildConfigPayload() {
|
|
@@ -3,9 +3,10 @@ import { CostWriteRequestSchema, HubEvents, } from "@naisys/hub-protocol";
|
|
|
3
3
|
const SPEND_LIMIT_CHECK_INTERVAL_MS = 10_000;
|
|
4
4
|
/** Handles cost_write events from NAISYS instances (fire-and-forget) */
|
|
5
5
|
export function createHubCostService(naisysServer, { hubDb }, logService, heartbeatService, configService) {
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
const
|
|
6
|
+
// Suspended users → original suspension reason. Stored so a defensive
|
|
7
|
+
// re-send (when a suspended user keeps writing costs) can use the same text.
|
|
8
|
+
const suspendedByGlobal = new Map();
|
|
9
|
+
const suspendedByAgent = new Map();
|
|
9
10
|
naisysServer.registerEvent(HubEvents.COST_WRITE, async (hostId, data, ack) => {
|
|
10
11
|
try {
|
|
11
12
|
const parsed = CostWriteRequestSchema.parse(data);
|
|
@@ -14,10 +15,14 @@ export function createHubCostService(naisysServer, { hubDb }, logService, heartb
|
|
|
14
15
|
const costPushMap = new Map();
|
|
15
16
|
const userCostTotals = new Map();
|
|
16
17
|
for (const entry of parsed.entries) {
|
|
18
|
+
const subagentId = entry.subagentId ?? 0;
|
|
19
|
+
// Wire format: undefined for parent (subagent_id 0 in DB), number otherwise
|
|
20
|
+
const wireSubagentId = subagentId === 0 ? undefined : subagentId;
|
|
17
21
|
await hubDb.costs.create({
|
|
18
22
|
data: {
|
|
19
23
|
user_id: entry.userId,
|
|
20
24
|
run_id: entry.runId,
|
|
25
|
+
subagent_id: subagentId,
|
|
21
26
|
session_id: entry.sessionId,
|
|
22
27
|
host_id: hostId,
|
|
23
28
|
source: entry.source,
|
|
@@ -34,13 +39,14 @@ export function createHubCostService(naisysServer, { hubDb }, logService, heartb
|
|
|
34
39
|
where: {
|
|
35
40
|
user_id: entry.userId,
|
|
36
41
|
run_id: entry.runId,
|
|
42
|
+
subagent_id: subagentId,
|
|
37
43
|
session_id: entry.sessionId,
|
|
38
44
|
},
|
|
39
45
|
data: {
|
|
40
46
|
total_cost: { increment: entry.cost },
|
|
41
47
|
},
|
|
42
48
|
});
|
|
43
|
-
const key = `${entry.userId}:${entry.runId}:${entry.sessionId}`;
|
|
49
|
+
const key = `${entry.userId}:${entry.runId}:${subagentId}:${entry.sessionId}`;
|
|
44
50
|
const existing = costPushMap.get(key);
|
|
45
51
|
if (existing) {
|
|
46
52
|
existing.costDelta += entry.cost;
|
|
@@ -49,6 +55,7 @@ export function createHubCostService(naisysServer, { hubDb }, logService, heartb
|
|
|
49
55
|
costPushMap.set(key, {
|
|
50
56
|
userId: entry.userId,
|
|
51
57
|
runId: entry.runId,
|
|
58
|
+
subagentId: wireSubagentId,
|
|
52
59
|
sessionId: entry.sessionId,
|
|
53
60
|
costDelta: entry.cost,
|
|
54
61
|
});
|
|
@@ -61,10 +68,12 @@ export function createHubCostService(naisysServer, { hubDb }, logService, heartb
|
|
|
61
68
|
entries: Array.from(costPushMap.values()),
|
|
62
69
|
});
|
|
63
70
|
}
|
|
64
|
-
// Re-send cost_control to
|
|
71
|
+
// Re-send cost_control to suspended users still writing costs.
|
|
72
|
+
// Per-agent reason wins: a per-agent limit overrides the global one.
|
|
65
73
|
for (const userId of userCostTotals.keys()) {
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
const reason = suspendedByAgent.get(userId) ?? suspendedByGlobal.get(userId);
|
|
75
|
+
if (reason !== undefined) {
|
|
76
|
+
sendCostControl(userId, false, reason);
|
|
68
77
|
}
|
|
69
78
|
}
|
|
70
79
|
// Decrement budget_left and return updated values
|
|
@@ -83,9 +92,9 @@ export function createHubCostService(naisysServer, { hubDb }, logService, heartb
|
|
|
83
92
|
async function checkSpendLimits(candidateUserIds) {
|
|
84
93
|
const activeUserIds = heartbeatService.getActiveUserIds();
|
|
85
94
|
const usersToCheck = new Set(activeUserIds);
|
|
86
|
-
for (const userId of suspendedByGlobal)
|
|
95
|
+
for (const userId of suspendedByGlobal.keys())
|
|
87
96
|
usersToCheck.add(userId);
|
|
88
|
-
for (const userId of suspendedByAgent)
|
|
97
|
+
for (const userId of suspendedByAgent.keys())
|
|
89
98
|
usersToCheck.add(userId);
|
|
90
99
|
if (candidateUserIds) {
|
|
91
100
|
for (const userId of candidateUserIds)
|
|
@@ -209,7 +218,7 @@ export function createHubCostService(naisysServer, { hubDb }, logService, heartb
|
|
|
209
218
|
const reason = `Global spend limit of $${spendLimit} reached (total: $${totalCost.toFixed(2)})`;
|
|
210
219
|
logService.log(`[Hub:Costs] Suspending user ${userId} (global limit): ${reason}`);
|
|
211
220
|
sendCostControl(userId, false, reason);
|
|
212
|
-
suspendedByGlobal.
|
|
221
|
+
suspendedByGlobal.set(userId, reason);
|
|
213
222
|
await setCostSuspendedReason(hubDb, userId, reason);
|
|
214
223
|
}
|
|
215
224
|
else if (!isOverLimit && wasSuspended) {
|
|
@@ -232,7 +241,7 @@ export function createHubCostService(naisysServer, { hubDb }, logService, heartb
|
|
|
232
241
|
const reason = `Spend limit of $${spendLimit} reached (current: $${periodCost.toFixed(2)})`;
|
|
233
242
|
logService.log(`[Hub:Costs] Suspending user ${userId}: ${reason}`);
|
|
234
243
|
sendCostControl(userId, false, reason);
|
|
235
|
-
suspendedByAgent.
|
|
244
|
+
suspendedByAgent.set(userId, reason);
|
|
236
245
|
await setCostSuspendedReason(hubDb, userId, reason);
|
|
237
246
|
}
|
|
238
247
|
else if (!isOverLimit && wasSuspended) {
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { buildDefaultAgentConfig } from "@naisys/common";
|
|
2
|
+
import { HubEvents } from "@naisys/hub-protocol";
|
|
3
|
+
import { describe, expect, test, vi } from "vitest";
|
|
4
|
+
import { createHubCostService } from "./hubCostService.js";
|
|
5
|
+
function createServerHarness() {
|
|
6
|
+
const handlers = new Map();
|
|
7
|
+
const server = {
|
|
8
|
+
registerEvent: vi.fn((event, handler) => {
|
|
9
|
+
handlers.set(event, handler);
|
|
10
|
+
}),
|
|
11
|
+
broadcastToSupervisors: vi.fn(),
|
|
12
|
+
sendMessage: vi.fn(() => true),
|
|
13
|
+
};
|
|
14
|
+
async function emitCostWrite(hostId, data) {
|
|
15
|
+
const handler = handlers.get(HubEvents.COST_WRITE);
|
|
16
|
+
if (!handler)
|
|
17
|
+
throw new Error("COST_WRITE handler was not registered");
|
|
18
|
+
let ackResponse;
|
|
19
|
+
await handler(hostId, data, (response) => {
|
|
20
|
+
ackResponse = response;
|
|
21
|
+
});
|
|
22
|
+
return ackResponse;
|
|
23
|
+
}
|
|
24
|
+
return { server, emitCostWrite };
|
|
25
|
+
}
|
|
26
|
+
function createHubDb() {
|
|
27
|
+
const budgetLeft = new Map([[1, 5]]);
|
|
28
|
+
const hubDb = {
|
|
29
|
+
costs: {
|
|
30
|
+
create: vi.fn(() => Promise.resolve({})),
|
|
31
|
+
aggregate: vi.fn(() => Promise.resolve({ _sum: { cost: 0 } })),
|
|
32
|
+
},
|
|
33
|
+
run_session: {
|
|
34
|
+
updateMany: vi.fn(() => Promise.resolve({})),
|
|
35
|
+
},
|
|
36
|
+
users: {
|
|
37
|
+
findMany: vi.fn(() => Promise.resolve([])),
|
|
38
|
+
},
|
|
39
|
+
user_notifications: {
|
|
40
|
+
findUnique: vi.fn(({ where }) => {
|
|
41
|
+
const value = budgetLeft.get(where.user_id);
|
|
42
|
+
return Promise.resolve(value === undefined ? null : { budget_left: value });
|
|
43
|
+
}),
|
|
44
|
+
update: vi.fn(({ where, data, }) => {
|
|
45
|
+
budgetLeft.set(where.user_id, data.budget_left);
|
|
46
|
+
return Promise.resolve({ budget_left: data.budget_left });
|
|
47
|
+
}),
|
|
48
|
+
updateMany: vi.fn(() => Promise.resolve({})),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
return { hubDb, budgetLeft };
|
|
52
|
+
}
|
|
53
|
+
function createLogger() {
|
|
54
|
+
return {
|
|
55
|
+
log: vi.fn(),
|
|
56
|
+
error: vi.fn(),
|
|
57
|
+
disableConsole: vi.fn(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function createHeartbeatService(activeUserIds, hostIdsByUser = new Map([[1, [101]]])) {
|
|
61
|
+
return {
|
|
62
|
+
getActiveUserIds: vi.fn(() => activeUserIds),
|
|
63
|
+
findHostsForAgent: vi.fn((userId) => hostIdsByUser.get(userId) ?? []),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function createConfigService(config) {
|
|
67
|
+
return {
|
|
68
|
+
getConfig: vi.fn(() => ({ config })),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function userRow(id, configOverrides, spendLimitResetAt) {
|
|
72
|
+
return {
|
|
73
|
+
id,
|
|
74
|
+
config: JSON.stringify({
|
|
75
|
+
...buildDefaultAgentConfig(`agent-${id}`),
|
|
76
|
+
...configOverrides,
|
|
77
|
+
}),
|
|
78
|
+
user_notifications: {
|
|
79
|
+
spend_limit_reset_at: spendLimitResetAt,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
describe("hubCostService", () => {
|
|
84
|
+
test("persists subagent cost entries, pushes scoped deltas, and decrements budget", async () => {
|
|
85
|
+
const { server, emitCostWrite } = createServerHarness();
|
|
86
|
+
const { hubDb } = createHubDb();
|
|
87
|
+
const logger = createLogger();
|
|
88
|
+
const heartbeatService = createHeartbeatService([]);
|
|
89
|
+
const configService = createConfigService({});
|
|
90
|
+
const service = createHubCostService(server, { hubDb }, logger, heartbeatService, configService);
|
|
91
|
+
const ack = await emitCostWrite(42, {
|
|
92
|
+
entries: [
|
|
93
|
+
{
|
|
94
|
+
userId: 1,
|
|
95
|
+
runId: 7,
|
|
96
|
+
subagentId: -1,
|
|
97
|
+
sessionId: 1,
|
|
98
|
+
source: "genimg",
|
|
99
|
+
model: "mock-image",
|
|
100
|
+
cost: 0.5,
|
|
101
|
+
inputTokens: 0,
|
|
102
|
+
outputTokens: 0,
|
|
103
|
+
cacheWriteTokens: 0,
|
|
104
|
+
cacheReadTokens: 0,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
userId: 1,
|
|
108
|
+
runId: 7,
|
|
109
|
+
subagentId: -1,
|
|
110
|
+
sessionId: 1,
|
|
111
|
+
source: "genimg",
|
|
112
|
+
model: "mock-image",
|
|
113
|
+
cost: 0.25,
|
|
114
|
+
inputTokens: 0,
|
|
115
|
+
outputTokens: 0,
|
|
116
|
+
cacheWriteTokens: 0,
|
|
117
|
+
cacheReadTokens: 0,
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
});
|
|
121
|
+
expect(hubDb.costs.create).toHaveBeenCalledWith({
|
|
122
|
+
data: expect.objectContaining({
|
|
123
|
+
user_id: 1,
|
|
124
|
+
run_id: 7,
|
|
125
|
+
subagent_id: -1,
|
|
126
|
+
session_id: 1,
|
|
127
|
+
host_id: 42,
|
|
128
|
+
cost: 0.5,
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
expect(hubDb.run_session.updateMany).toHaveBeenCalledWith({
|
|
132
|
+
where: {
|
|
133
|
+
user_id: 1,
|
|
134
|
+
run_id: 7,
|
|
135
|
+
subagent_id: -1,
|
|
136
|
+
session_id: 1,
|
|
137
|
+
},
|
|
138
|
+
data: {
|
|
139
|
+
total_cost: { increment: 0.5 },
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
expect(server.broadcastToSupervisors).toHaveBeenCalledWith(HubEvents.COST_PUSH, {
|
|
143
|
+
entries: [
|
|
144
|
+
{
|
|
145
|
+
userId: 1,
|
|
146
|
+
runId: 7,
|
|
147
|
+
subagentId: -1,
|
|
148
|
+
sessionId: 1,
|
|
149
|
+
costDelta: 0.75,
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
expect(hubDb.user_notifications.update).toHaveBeenCalledWith({
|
|
154
|
+
where: { user_id: 1 },
|
|
155
|
+
data: { budget_left: 4.25 },
|
|
156
|
+
});
|
|
157
|
+
expect(ack).toEqual({ budgets: [{ userId: 1, budgetLeft: 4.25 }] });
|
|
158
|
+
service.cleanup();
|
|
159
|
+
});
|
|
160
|
+
test("normalizes parent-agent subagentId to undefined on the wire (DB row stays 0)", async () => {
|
|
161
|
+
const { server, emitCostWrite } = createServerHarness();
|
|
162
|
+
const { hubDb } = createHubDb();
|
|
163
|
+
const logger = createLogger();
|
|
164
|
+
const heartbeatService = createHeartbeatService([]);
|
|
165
|
+
const configService = createConfigService({});
|
|
166
|
+
const service = createHubCostService(server, { hubDb }, logger, heartbeatService, configService);
|
|
167
|
+
await emitCostWrite(42, {
|
|
168
|
+
entries: [
|
|
169
|
+
{
|
|
170
|
+
userId: 1,
|
|
171
|
+
runId: 7,
|
|
172
|
+
// subagentId omitted — represents the parent agent
|
|
173
|
+
sessionId: 1,
|
|
174
|
+
source: "console",
|
|
175
|
+
model: "mock",
|
|
176
|
+
cost: 0.1,
|
|
177
|
+
inputTokens: 0,
|
|
178
|
+
outputTokens: 0,
|
|
179
|
+
cacheWriteTokens: 0,
|
|
180
|
+
cacheReadTokens: 0,
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
expect(hubDb.costs.create).toHaveBeenCalledWith({
|
|
185
|
+
data: expect.objectContaining({ subagent_id: 0 }),
|
|
186
|
+
});
|
|
187
|
+
expect(server.broadcastToSupervisors).toHaveBeenCalledWith(HubEvents.COST_PUSH, {
|
|
188
|
+
entries: [
|
|
189
|
+
{
|
|
190
|
+
userId: 1,
|
|
191
|
+
runId: 7,
|
|
192
|
+
subagentId: undefined,
|
|
193
|
+
sessionId: 1,
|
|
194
|
+
costDelta: 0.1,
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
service.cleanup();
|
|
199
|
+
});
|
|
200
|
+
test("suspends, re-sends, and resumes per-agent cost control", async () => {
|
|
201
|
+
const { server, emitCostWrite } = createServerHarness();
|
|
202
|
+
const { hubDb } = createHubDb();
|
|
203
|
+
const logger = createLogger();
|
|
204
|
+
const heartbeatService = createHeartbeatService([1]);
|
|
205
|
+
const configService = createConfigService({});
|
|
206
|
+
vi.mocked(hubDb.users.findMany).mockResolvedValue([
|
|
207
|
+
userRow(1, { spendLimitDollars: 1 }),
|
|
208
|
+
]);
|
|
209
|
+
vi.mocked(hubDb.costs.aggregate)
|
|
210
|
+
.mockResolvedValueOnce({ _sum: { cost: 1.25 } })
|
|
211
|
+
.mockResolvedValueOnce({ _sum: { cost: 0.25 } });
|
|
212
|
+
const service = createHubCostService(server, { hubDb }, logger, heartbeatService, configService);
|
|
213
|
+
await service.checkSpendLimits();
|
|
214
|
+
const suspendReason = "Spend limit of $1 reached (current: $1.25)";
|
|
215
|
+
expect(server.sendMessage).toHaveBeenCalledWith(101, HubEvents.COST_CONTROL, {
|
|
216
|
+
userId: 1,
|
|
217
|
+
enabled: false,
|
|
218
|
+
reason: suspendReason,
|
|
219
|
+
});
|
|
220
|
+
expect(service.isUserSpendSuspended(1)).toBe(true);
|
|
221
|
+
expect(hubDb.user_notifications.updateMany).toHaveBeenCalledWith({
|
|
222
|
+
where: { user_id: 1 },
|
|
223
|
+
data: { cost_suspended_reason: suspendReason },
|
|
224
|
+
});
|
|
225
|
+
vi.mocked(server.sendMessage).mockClear();
|
|
226
|
+
await emitCostWrite(42, {
|
|
227
|
+
entries: [
|
|
228
|
+
{
|
|
229
|
+
userId: 1,
|
|
230
|
+
runId: 7,
|
|
231
|
+
subagentId: -1,
|
|
232
|
+
sessionId: 1,
|
|
233
|
+
source: "genimg",
|
|
234
|
+
model: "mock-image",
|
|
235
|
+
cost: 0.1,
|
|
236
|
+
inputTokens: 0,
|
|
237
|
+
outputTokens: 0,
|
|
238
|
+
cacheWriteTokens: 0,
|
|
239
|
+
cacheReadTokens: 0,
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
expect(server.sendMessage).toHaveBeenCalledWith(101, HubEvents.COST_CONTROL, {
|
|
244
|
+
userId: 1,
|
|
245
|
+
enabled: false,
|
|
246
|
+
reason: suspendReason,
|
|
247
|
+
});
|
|
248
|
+
vi.mocked(server.sendMessage).mockClear();
|
|
249
|
+
await service.checkSpendLimits();
|
|
250
|
+
expect(server.sendMessage).toHaveBeenCalledWith(101, HubEvents.COST_CONTROL, {
|
|
251
|
+
userId: 1,
|
|
252
|
+
enabled: true,
|
|
253
|
+
reason: "Spend limit period reset (current: $0.25, limit: $1)",
|
|
254
|
+
});
|
|
255
|
+
expect(service.isUserSpendSuspended(1)).toBe(false);
|
|
256
|
+
expect(hubDb.user_notifications.updateMany).toHaveBeenCalledWith({
|
|
257
|
+
where: { user_id: 1 },
|
|
258
|
+
data: { cost_suspended_reason: null },
|
|
259
|
+
});
|
|
260
|
+
service.cleanup();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
//# sourceMappingURL=hubCostService.test.js.map
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { HeartbeatSchema, HUB_HEARTBEAT_INTERVAL_MS, HubEvents, } from "@naisys/hub-protocol";
|
|
2
2
|
/** Tracks NAISYS instance heartbeats and pushes aggregate active user status to all instances */
|
|
3
3
|
export function createHubHeartbeatService(naisysServer, { hubDb }, logService) {
|
|
4
|
-
//
|
|
4
|
+
// Active agent user ids per host. Subagents ride under the parent's userId.
|
|
5
5
|
const hostActiveAgents = new Map();
|
|
6
|
-
// Track each active agent's current run session and its last heartbeat time.
|
|
7
|
-
// Keyed by hostId so we can drop sessions when a host disconnects.
|
|
8
6
|
const hostActiveSessions = new Map();
|
|
7
|
+
const sessionKey = (userId, subagentId) => `${userId}:${subagentId ?? 0}`;
|
|
9
8
|
// Track per-agent notification IDs (latestLogId, latestMailId)
|
|
10
9
|
const agentNotifications = new Map();
|
|
11
10
|
/** Update a single notification field for an agent */
|
|
@@ -20,7 +19,10 @@ export function createHubHeartbeatService(naisysServer, { hubDb }, logService) {
|
|
|
20
19
|
// Handle heartbeat from NAISYS instances
|
|
21
20
|
naisysServer.registerEvent(HubEvents.HEARTBEAT, async (hostId, data) => {
|
|
22
21
|
const parsed = HeartbeatSchema.parse(data);
|
|
23
|
-
|
|
22
|
+
// Dedup: a parent and its subagents share a userId.
|
|
23
|
+
const activeUserIds = [
|
|
24
|
+
...new Set(parsed.activeSessions.map((s) => s.userId)),
|
|
25
|
+
];
|
|
24
26
|
// Update in-memory per-host active agent IDs
|
|
25
27
|
hostActiveAgents.set(hostId, activeUserIds);
|
|
26
28
|
try {
|
|
@@ -42,15 +44,10 @@ export function createHubHeartbeatService(naisysServer, { hubDb }, logService) {
|
|
|
42
44
|
// aggregate SESSION_HEARTBEAT broadcast runs on its own interval below.
|
|
43
45
|
const sessionMap = new Map();
|
|
44
46
|
for (const session of parsed.activeSessions) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
session_id: session.sessionId,
|
|
50
|
-
},
|
|
51
|
-
data: { last_active: now },
|
|
52
|
-
});
|
|
53
|
-
sessionMap.set(session.userId, {
|
|
47
|
+
const subagentId = session.subagentId ?? 0;
|
|
48
|
+
sessionMap.set(sessionKey(session.userId, subagentId), {
|
|
49
|
+
userId: session.userId,
|
|
50
|
+
subagentId: subagentId === 0 ? null : subagentId,
|
|
54
51
|
runId: session.runId,
|
|
55
52
|
sessionId: session.sessionId,
|
|
56
53
|
lastActive: now,
|
|
@@ -58,13 +55,28 @@ export function createHubHeartbeatService(naisysServer, { hubDb }, logService) {
|
|
|
58
55
|
state: session.state,
|
|
59
56
|
});
|
|
60
57
|
}
|
|
58
|
+
if (parsed.activeSessions.length > 0) {
|
|
59
|
+
await hubDb.run_session.updateMany({
|
|
60
|
+
where: {
|
|
61
|
+
OR: parsed.activeSessions.map((session) => ({
|
|
62
|
+
user_id: session.userId,
|
|
63
|
+
run_id: session.runId,
|
|
64
|
+
subagent_id: session.subagentId ?? 0,
|
|
65
|
+
session_id: session.sessionId,
|
|
66
|
+
})),
|
|
67
|
+
},
|
|
68
|
+
data: { last_active: now },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
61
71
|
hostActiveSessions.set(hostId, sessionMap);
|
|
62
72
|
}
|
|
63
73
|
catch (error) {
|
|
64
74
|
logService.error(`[Hub:Heartbeat] Error updating heartbeat for host ${hostId}: ${error}`);
|
|
65
75
|
}
|
|
66
76
|
});
|
|
67
|
-
// Clean up tracking when a host disconnects
|
|
77
|
+
// Clean up tracking when a host disconnects. Runtime API keys are not
|
|
78
|
+
// touched here — they're rotated on the next AGENT_START and revoked
|
|
79
|
+
// explicitly on user disable/archive/delete.
|
|
68
80
|
naisysServer.registerEvent(HubEvents.CLIENT_DISCONNECTED, (hostId) => {
|
|
69
81
|
hostActiveAgents.delete(hostId);
|
|
70
82
|
hostActiveSessions.delete(hostId);
|
|
@@ -97,10 +109,11 @@ export function createHubHeartbeatService(naisysServer, { hubDb }, logService) {
|
|
|
97
109
|
function pushSessionHeartbeat() {
|
|
98
110
|
const updates = [];
|
|
99
111
|
for (const sessions of hostActiveSessions.values()) {
|
|
100
|
-
for (const
|
|
112
|
+
for (const info of sessions.values()) {
|
|
101
113
|
updates.push({
|
|
102
|
-
userId,
|
|
114
|
+
userId: info.userId,
|
|
103
115
|
runId: info.runId,
|
|
116
|
+
subagentId: info.subagentId ?? undefined,
|
|
104
117
|
sessionId: info.sessionId,
|
|
105
118
|
lastActive: info.lastActive,
|
|
106
119
|
paused: info.paused,
|
|
@@ -37,6 +37,9 @@ export function createHubHostService(naisysServer, hostRegistrar, logService) {
|
|
|
37
37
|
naisysServer.registerEvent(HubEvents.CLIENT_CONNECTED, (_hostId, connection) => {
|
|
38
38
|
broadcastHostList(connection);
|
|
39
39
|
});
|
|
40
|
+
naisysServer.registerEvent(HubEvents.CLIENT_DISCONNECTED, () => {
|
|
41
|
+
broadcastHostList();
|
|
42
|
+
});
|
|
40
43
|
naisysServer.registerEvent(HubEvents.HOSTS_CHANGED, async () => {
|
|
41
44
|
logService.log("[Hub:Hosts] Received HOSTS_CHANGED, refreshing cache...");
|
|
42
45
|
await hostRegistrar.refreshHosts();
|
|
@@ -12,10 +12,14 @@ export function createHubLogService(naisysServer, { hubDb }, logService, heartbe
|
|
|
12
12
|
for (const entry of parsed.entries) {
|
|
13
13
|
const now = new Date().toISOString();
|
|
14
14
|
const lineCount = entry.message.split("\n").length;
|
|
15
|
+
const subagentId = entry.subagentId ?? 0;
|
|
16
|
+
// Wire format: undefined for parent (subagent_id 0 in DB), number otherwise
|
|
17
|
+
const wireSubagentId = subagentId === 0 ? undefined : subagentId;
|
|
15
18
|
const log = await hubDb.context_log.create({
|
|
16
19
|
data: {
|
|
17
20
|
user_id: entry.userId,
|
|
18
21
|
run_id: entry.runId,
|
|
22
|
+
subagent_id: subagentId,
|
|
19
23
|
session_id: entry.sessionId,
|
|
20
24
|
host_id: hostId,
|
|
21
25
|
role: entry.role,
|
|
@@ -31,6 +35,7 @@ export function createHubLogService(naisysServer, { hubDb }, logService, heartbe
|
|
|
31
35
|
where: {
|
|
32
36
|
user_id: entry.userId,
|
|
33
37
|
run_id: entry.runId,
|
|
38
|
+
subagent_id: subagentId,
|
|
34
39
|
session_id: entry.sessionId,
|
|
35
40
|
},
|
|
36
41
|
data: {
|
|
@@ -69,13 +74,14 @@ export function createHubLogService(naisysServer, { hubDb }, logService, heartbe
|
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
76
|
// Collect push entry with DB-assigned ID
|
|
72
|
-
const sessionKey = `${entry.userId}-${entry.runId}-${entry.sessionId}`;
|
|
77
|
+
const sessionKey = `${entry.userId}-${entry.runId}-${subagentId}-${entry.sessionId}`;
|
|
73
78
|
const previousId = lastPushedLogId.get(sessionKey) ?? null;
|
|
74
79
|
pushEntries.push({
|
|
75
80
|
id: log.id,
|
|
76
81
|
previousId,
|
|
77
82
|
userId: entry.userId,
|
|
78
83
|
runId: entry.runId,
|
|
84
|
+
subagentId: wireSubagentId,
|
|
79
85
|
sessionId: entry.sessionId,
|
|
80
86
|
role: entry.role,
|
|
81
87
|
source: entry.source,
|
|
@@ -98,6 +104,7 @@ export function createHubLogService(naisysServer, { hubDb }, logService, heartbe
|
|
|
98
104
|
sessionUpdates.set(sessionKey, {
|
|
99
105
|
userId: entry.userId,
|
|
100
106
|
runId: entry.runId,
|
|
107
|
+
subagentId: wireSubagentId,
|
|
101
108
|
sessionId: entry.sessionId,
|
|
102
109
|
lastActive: now,
|
|
103
110
|
latestLogId: log.id,
|
|
@@ -14,18 +14,42 @@ export function createHubRunService(naisysServer, { hubDb }, logService) {
|
|
|
14
14
|
naisysServer.registerEvent(HubEvents.SESSION_CREATE, async (hostId, data, ack) => {
|
|
15
15
|
try {
|
|
16
16
|
const parsed = SessionCreateRequestSchema.parse(data);
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
// Subagent path: inherit the parent's runId rather than allocating a new one.
|
|
18
|
+
let runId;
|
|
19
|
+
if (parsed.subagentId !== undefined) {
|
|
20
|
+
if (parsed.parentRunId === undefined) {
|
|
21
|
+
throw new Error("parentRunId is required when subagentId is set");
|
|
22
|
+
}
|
|
23
|
+
// Confirm the parent's run row exists before materializing a child
|
|
24
|
+
// under it — otherwise we'd silently create an orphan subagent row.
|
|
25
|
+
const parent = await hubDb.run_session.findFirst({
|
|
26
|
+
where: {
|
|
27
|
+
user_id: parsed.userId,
|
|
28
|
+
run_id: parsed.parentRunId,
|
|
29
|
+
subagent_id: 0,
|
|
30
|
+
},
|
|
31
|
+
select: { run_id: true },
|
|
32
|
+
});
|
|
33
|
+
if (!parent) {
|
|
34
|
+
throw new Error(`parent run ${parsed.parentRunId} not found for user ${parsed.userId}`);
|
|
35
|
+
}
|
|
36
|
+
runId = parsed.parentRunId;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const lastRun = await hubDb.run_session.findFirst({
|
|
40
|
+
select: { run_id: true },
|
|
41
|
+
orderBy: { run_id: "desc" },
|
|
42
|
+
});
|
|
43
|
+
runId = lastRun ? lastRun.run_id + 1 : 1;
|
|
44
|
+
}
|
|
45
|
+
const subagentId = parsed.subagentId ?? 0;
|
|
23
46
|
const newSessionId = 1;
|
|
24
47
|
const now = new Date().toISOString();
|
|
25
48
|
await hubDb.run_session.create({
|
|
26
49
|
data: {
|
|
27
50
|
user_id: parsed.userId,
|
|
28
|
-
run_id:
|
|
51
|
+
run_id: runId,
|
|
52
|
+
subagent_id: subagentId,
|
|
29
53
|
session_id: newSessionId,
|
|
30
54
|
host_id: hostId,
|
|
31
55
|
model_name: parsed.modelName,
|
|
@@ -35,12 +59,13 @@ export function createHubRunService(naisysServer, { hubDb }, logService) {
|
|
|
35
59
|
});
|
|
36
60
|
ack({
|
|
37
61
|
success: true,
|
|
38
|
-
runId
|
|
62
|
+
runId,
|
|
39
63
|
sessionId: newSessionId,
|
|
40
64
|
});
|
|
41
65
|
pushSessionToSupervisors({
|
|
42
66
|
userId: parsed.userId,
|
|
43
|
-
runId
|
|
67
|
+
runId,
|
|
68
|
+
subagentId: parsed.subagentId,
|
|
44
69
|
sessionId: newSessionId,
|
|
45
70
|
modelName: parsed.modelName,
|
|
46
71
|
createdAt: now,
|
|
@@ -55,12 +80,14 @@ export function createHubRunService(naisysServer, { hubDb }, logService) {
|
|
|
55
80
|
naisysServer.registerEvent(HubEvents.SESSION_INCREMENT, async (hostId, data, ack) => {
|
|
56
81
|
try {
|
|
57
82
|
const parsed = SessionIncrementRequestSchema.parse(data);
|
|
58
|
-
|
|
83
|
+
const subagentId = parsed.subagentId ?? 0;
|
|
84
|
+
// Get the max session_id for this user + run + subagent
|
|
59
85
|
const lastSession = await hubDb.run_session.findFirst({
|
|
60
86
|
select: { session_id: true },
|
|
61
87
|
where: {
|
|
62
88
|
user_id: parsed.userId,
|
|
63
89
|
run_id: parsed.runId,
|
|
90
|
+
subagent_id: subagentId,
|
|
64
91
|
},
|
|
65
92
|
orderBy: { session_id: "desc" },
|
|
66
93
|
});
|
|
@@ -70,6 +97,7 @@ export function createHubRunService(naisysServer, { hubDb }, logService) {
|
|
|
70
97
|
data: {
|
|
71
98
|
user_id: parsed.userId,
|
|
72
99
|
run_id: parsed.runId,
|
|
100
|
+
subagent_id: subagentId,
|
|
73
101
|
session_id: newSessionId,
|
|
74
102
|
host_id: hostId,
|
|
75
103
|
model_name: parsed.modelName,
|
|
@@ -81,6 +109,7 @@ export function createHubRunService(naisysServer, { hubDb }, logService) {
|
|
|
81
109
|
pushSessionToSupervisors({
|
|
82
110
|
userId: parsed.userId,
|
|
83
111
|
runId: parsed.runId,
|
|
112
|
+
subagentId: parsed.subagentId,
|
|
84
113
|
sessionId: newSessionId,
|
|
85
114
|
modelName: parsed.modelName,
|
|
86
115
|
createdAt: now,
|
|
@@ -10,7 +10,6 @@ export function createHubUserService(naisysServer, { hubDb }, logService) {
|
|
|
10
10
|
enabled: true,
|
|
11
11
|
config: true,
|
|
12
12
|
lead_user_id: true,
|
|
13
|
-
api_key: true,
|
|
14
13
|
user_hosts: {
|
|
15
14
|
select: { host_id: true },
|
|
16
15
|
},
|
|
@@ -25,7 +24,6 @@ export function createHubUserService(naisysServer, { hubDb }, logService) {
|
|
|
25
24
|
assignedHostIds: u.user_hosts.length > 0
|
|
26
25
|
? u.user_hosts.map((uh) => uh.host_id)
|
|
27
26
|
: undefined,
|
|
28
|
-
apiKey: u.api_key || undefined,
|
|
29
27
|
}));
|
|
30
28
|
return { success: true, users };
|
|
31
29
|
}
|
package/dist/naisysHub.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createDualLogger, cwdWithTilde, ensureDotEnv, expandNaisysFolder,
|
|
1
|
+
import { createDualLogger, cwdWithTilde, ensureDotEnv, expandNaisysFolder, promptResetSuperAdminAccount, runSetupWizard, } from "@naisys/common-node";
|
|
2
2
|
import { createHubDatabaseService } from "@naisys/hub-database";
|
|
3
3
|
import { program } from "commander";
|
|
4
4
|
import dotenv from "dotenv";
|
|
@@ -29,6 +29,7 @@ import { createNaisysServer } from "./services/naisysServer.js";
|
|
|
29
29
|
export const startHub = async (startupType, startSupervisor, plugins, startupAgentPath, wizardRan) => {
|
|
30
30
|
try {
|
|
31
31
|
const agentPath = startupAgentPath || ".";
|
|
32
|
+
let cleanupSupervisor;
|
|
32
33
|
// Create log service first
|
|
33
34
|
const logService = createDualLogger("hub-server.log");
|
|
34
35
|
logService.log(`[Hub] Starting Hub server in ${startupType} mode...`);
|
|
@@ -43,8 +44,9 @@ export const startHub = async (startupType, startSupervisor, plugins, startupAge
|
|
|
43
44
|
await seedAgentConfigs(hubDatabaseService, logService, agentPath);
|
|
44
45
|
// Create host registrar for tracking NAISYS instance connections
|
|
45
46
|
const hostRegistrar = await createHostRegistrar(hubDatabaseService);
|
|
46
|
-
//
|
|
47
|
-
|
|
47
|
+
// trustProxy: TLS terminates at the reverse proxy, so honor X-Forwarded-*
|
|
48
|
+
// headers — otherwise request.protocol reads the internal http hop.
|
|
49
|
+
const fastify = Fastify({ pluginTimeout: 60_000, trustProxy: true });
|
|
48
50
|
// Register HTTP attachment upload/download routes
|
|
49
51
|
createHubAttachmentService(fastify, hubDatabaseService, logService);
|
|
50
52
|
// Attach Socket.IO to the underlying HTTP server.
|
|
@@ -78,7 +80,7 @@ export const startHub = async (startupType, startSupervisor, plugins, startupAge
|
|
|
78
80
|
// Register hub agent service for agent_start requests routed to target hosts
|
|
79
81
|
const agentService = createHubAgentService(naisysServer, hubDatabaseService, logService, heartbeatService, sendMailService, hostRegistrar);
|
|
80
82
|
// Register hub mail service for mail events from NAISYS instances
|
|
81
|
-
createHubMailService(naisysServer, hubDatabaseService, logService, heartbeatService, sendMailService, agentService, costService, configService);
|
|
83
|
+
const mailService = createHubMailService(naisysServer, hubDatabaseService, logService, heartbeatService, sendMailService, agentService, costService, configService);
|
|
82
84
|
/**
|
|
83
85
|
* There should be no dependency between supervisor and hub
|
|
84
86
|
* Sharing the same process space is to save 150 mb of node.js runtime memory on small servers
|
|
@@ -87,12 +89,16 @@ export const startHub = async (startupType, startSupervisor, plugins, startupAge
|
|
|
87
89
|
// Don't import the whole fastify web server module tree unless needed
|
|
88
90
|
// Use variable to avoid compile-time type dependency on @naisys/supervisor (allows parallel builds)
|
|
89
91
|
const supervisorModule = "@naisys/supervisor";
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
92
|
+
const hostedSupervisor = (await import(supervisorModule));
|
|
93
|
+
const { supervisorPlugin, bootstrapSupervisor } = hostedSupervisor;
|
|
94
|
+
cleanupSupervisor = hostedSupervisor.cleanupSupervisor;
|
|
95
|
+
const resetSuperAdminAccount = wizardRan
|
|
96
|
+
? await promptResetSuperAdminAccount("Supervisor Setup", {
|
|
97
|
+
defaultReset: !process.argv.includes("--setup"),
|
|
98
|
+
})
|
|
93
99
|
: false;
|
|
94
100
|
// Bootstrap before plugin register so the operator prompt isn't bounded by pluginTimeout and doesn't interleave with hub connection logs.
|
|
95
|
-
await bootstrapSupervisor({
|
|
101
|
+
await bootstrapSupervisor({ resetSuperAdminAccount });
|
|
96
102
|
await fastify.register(supervisorPlugin, {
|
|
97
103
|
plugins,
|
|
98
104
|
serverPort,
|
|
@@ -105,6 +111,24 @@ export const startHub = async (startupType, startSupervisor, plugins, startupAge
|
|
|
105
111
|
if (startupType === "hosted") {
|
|
106
112
|
logService.disableConsole();
|
|
107
113
|
}
|
|
114
|
+
let shutdownPromise = null;
|
|
115
|
+
// Like NAISYS: process exit reaps sockets and Fastify. Clear known timers
|
|
116
|
+
// synchronously, but only wait for the DB disconnect.
|
|
117
|
+
async function runShutdown() {
|
|
118
|
+
try {
|
|
119
|
+
cleanupSupervisor?.();
|
|
120
|
+
heartbeatService.cleanup();
|
|
121
|
+
costService.cleanup();
|
|
122
|
+
mailService.cleanup();
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await hubDatabaseService.disconnect();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const shutdown = () => {
|
|
129
|
+
shutdownPromise ??= runShutdown();
|
|
130
|
+
return shutdownPromise;
|
|
131
|
+
};
|
|
108
132
|
// Hosted mode: parent process owns signal handling
|
|
109
133
|
if (startupType === "standalone") {
|
|
110
134
|
let shuttingDown = false;
|
|
@@ -116,8 +140,7 @@ export const startHub = async (startupType, startSupervisor, plugins, startupAge
|
|
|
116
140
|
shuttingDown = true;
|
|
117
141
|
logService.log(`[Hub] Shutting down (${signal})...`);
|
|
118
142
|
try {
|
|
119
|
-
await
|
|
120
|
-
await fastify.close();
|
|
143
|
+
await shutdown();
|
|
121
144
|
}
|
|
122
145
|
catch (err) {
|
|
123
146
|
console.error("[Hub] Error during shutdown:", err);
|
|
@@ -127,7 +150,7 @@ export const startHub = async (startupType, startSupervisor, plugins, startupAge
|
|
|
127
150
|
process.on("SIGTERM", () => void handleShutdown("SIGTERM"));
|
|
128
151
|
process.on("SIGINT", () => void handleShutdown("SIGINT"));
|
|
129
152
|
}
|
|
130
|
-
return { serverPort };
|
|
153
|
+
return { serverPort, shutdown };
|
|
131
154
|
}
|
|
132
155
|
catch (err) {
|
|
133
156
|
console.error("[Hub] Failed to start hub server:", err);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { adminAgentConfig, toUrlSafeKey } from "@naisys/common";
|
|
2
2
|
import { loadAgentConfigs } from "@naisys/common-node";
|
|
3
|
-
import {
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
4
|
/** Seeds agent configs from YAML files into an empty database. Skips if users already exist. */
|
|
5
5
|
export async function seedAgentConfigs({ hubDb }, logService, startupAgentPath) {
|
|
6
6
|
// Check if users table already has rows (seed-once pattern)
|
|
@@ -38,7 +38,6 @@ async function seedUsersToDatabase(hubDb, logService, users) {
|
|
|
38
38
|
username: safeUsername,
|
|
39
39
|
title: user.config.title,
|
|
40
40
|
config: JSON.stringify({ ...user.config, username: safeUsername }),
|
|
41
|
-
api_key: randomBytes(32).toString("hex"),
|
|
42
41
|
},
|
|
43
42
|
});
|
|
44
43
|
loaderIdToDbId.set(user.userId, dbUser.id);
|
|
@@ -68,7 +68,10 @@ export function createNaisysServer(nsp, initialHubAccessKey, logService, hostReg
|
|
|
68
68
|
const { hubAccessKey: clientAccessKey, hostName, machineId: rawMachineId, instanceId: rawInstanceId, startedAt: rawStartedAt, hostType: rawHostType, clientVersion, environment: rawEnvironment, } = socket.handshake.auth;
|
|
69
69
|
if (!clientAccessKey || clientAccessKey !== hubAccessKey) {
|
|
70
70
|
logService.log(`[Hub] Connection rejected: invalid access key from ${socket.handshake.address}`);
|
|
71
|
-
|
|
71
|
+
// Non-fatal: keys can rotate while a client is in retry; the client's
|
|
72
|
+
// auth callback re-reads the key on each attempt so the next try picks
|
|
73
|
+
// up the new value.
|
|
74
|
+
return next(createConnectError("Invalid access key", "invalid_access_key", false));
|
|
72
75
|
}
|
|
73
76
|
if (!hostName) {
|
|
74
77
|
logService.log(`[Hub] Connection rejected: missing hostName`);
|
|
@@ -123,7 +126,9 @@ export function createNaisysServer(nsp, initialHubAccessKey, logService, hostReg
|
|
|
123
126
|
}
|
|
124
127
|
catch (err) {
|
|
125
128
|
logService.error(`[Hub] Connection rejected: failed to register host ${hostName}: ${err}`);
|
|
126
|
-
|
|
129
|
+
// Non-fatal: registration touches the DB and can hit transient failures
|
|
130
|
+
// (pool timeout, deadlock); let the client keep retrying.
|
|
131
|
+
return next(createConnectError("NAISYS instance registration failed", "registration_failed", false));
|
|
127
132
|
}
|
|
128
133
|
});
|
|
129
134
|
// Handle new connections
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naisys/hub",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.39",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@naisys/hub",
|
|
9
|
-
"version": "3.0.0-beta.
|
|
9
|
+
"version": "3.0.0-beta.39",
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@naisys/common": "3.0.0-beta.
|
|
12
|
-
"@naisys/common-node": "3.0.0-beta.
|
|
13
|
-
"@naisys/hub-database": "3.0.0-beta.
|
|
14
|
-
"@naisys/hub-protocol": "3.0.0-beta.
|
|
11
|
+
"@naisys/common": "3.0.0-beta.39",
|
|
12
|
+
"@naisys/common-node": "3.0.0-beta.39",
|
|
13
|
+
"@naisys/hub-database": "3.0.0-beta.39",
|
|
14
|
+
"@naisys/hub-protocol": "3.0.0-beta.39",
|
|
15
15
|
"commander": "^14.0.3",
|
|
16
16
|
"dotenv": "^17.3.1",
|
|
17
17
|
"fastify": "^5.8.2",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"node": ">=22.0.0"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
|
-
"@naisys/supervisor": "3.0.0-beta.
|
|
27
|
+
"@naisys/supervisor": "3.0.0-beta.39"
|
|
28
28
|
},
|
|
29
29
|
"peerDependenciesMeta": {
|
|
30
30
|
"@naisys/supervisor": {
|
|
@@ -189,32 +189,32 @@
|
|
|
189
189
|
"license": "MIT"
|
|
190
190
|
},
|
|
191
191
|
"node_modules/@naisys/common": {
|
|
192
|
-
"version": "3.0.0-beta.
|
|
193
|
-
"resolved": "https://registry.npmjs.org/@naisys/common/-/common-3.0.0-beta.
|
|
194
|
-
"integrity": "sha512-
|
|
192
|
+
"version": "3.0.0-beta.39",
|
|
193
|
+
"resolved": "https://registry.npmjs.org/@naisys/common/-/common-3.0.0-beta.39.tgz",
|
|
194
|
+
"integrity": "sha512-H65s+TNhjKC+6piF9sP9LpydRL+K7IEyD6gUgXDekiQ1+6OV9xgxzZmeOgTAGs/Mk3fSs73OJ6JC4WPjBwVSdA==",
|
|
195
195
|
"dependencies": {
|
|
196
196
|
"semver": "^7.7.4",
|
|
197
197
|
"zod": "^4.3.6"
|
|
198
198
|
}
|
|
199
199
|
},
|
|
200
200
|
"node_modules/@naisys/common-node": {
|
|
201
|
-
"version": "3.0.0-beta.
|
|
202
|
-
"resolved": "https://registry.npmjs.org/@naisys/common-node/-/common-node-3.0.0-beta.
|
|
203
|
-
"integrity": "sha512-
|
|
201
|
+
"version": "3.0.0-beta.39",
|
|
202
|
+
"resolved": "https://registry.npmjs.org/@naisys/common-node/-/common-node-3.0.0-beta.39.tgz",
|
|
203
|
+
"integrity": "sha512-UGQwcHx8hbeTHjYKYB0Y9BQouwip614bOxUXeo2U+552gUTjmdjc2T7ng14opVqtZmYYzTidLWQrblS8mNa+sw==",
|
|
204
204
|
"dependencies": {
|
|
205
|
-
"@naisys/common": "3.0.0-beta.
|
|
205
|
+
"@naisys/common": "3.0.0-beta.39",
|
|
206
206
|
"better-sqlite3": "^12.6.2",
|
|
207
207
|
"js-yaml": "^4.1.1",
|
|
208
208
|
"pino": "^10.3.1"
|
|
209
209
|
}
|
|
210
210
|
},
|
|
211
211
|
"node_modules/@naisys/hub-database": {
|
|
212
|
-
"version": "3.0.0-beta.
|
|
213
|
-
"resolved": "https://registry.npmjs.org/@naisys/hub-database/-/hub-database-3.0.0-beta.
|
|
214
|
-
"integrity": "sha512-
|
|
212
|
+
"version": "3.0.0-beta.39",
|
|
213
|
+
"resolved": "https://registry.npmjs.org/@naisys/hub-database/-/hub-database-3.0.0-beta.39.tgz",
|
|
214
|
+
"integrity": "sha512-rTKKwhRWGp/5LcBiFLurY3VYdk6a5YNiEj7nfGMWYGbTozTw1X6PlCoBT3UV6g/brFPKDIlsS9tMDAhI/ibuuw==",
|
|
215
215
|
"dependencies": {
|
|
216
|
-
"@naisys/common": "3.0.0-beta.
|
|
217
|
-
"@naisys/common-node": "3.0.0-beta.
|
|
216
|
+
"@naisys/common": "3.0.0-beta.39",
|
|
217
|
+
"@naisys/common-node": "3.0.0-beta.39",
|
|
218
218
|
"@prisma/adapter-better-sqlite3": "^7.5.0",
|
|
219
219
|
"@prisma/client": "^7.5.0",
|
|
220
220
|
"better-sqlite3": "^12.6.2",
|
|
@@ -222,11 +222,11 @@
|
|
|
222
222
|
}
|
|
223
223
|
},
|
|
224
224
|
"node_modules/@naisys/hub-protocol": {
|
|
225
|
-
"version": "3.0.0-beta.
|
|
226
|
-
"resolved": "https://registry.npmjs.org/@naisys/hub-protocol/-/hub-protocol-3.0.0-beta.
|
|
227
|
-
"integrity": "sha512-
|
|
225
|
+
"version": "3.0.0-beta.39",
|
|
226
|
+
"resolved": "https://registry.npmjs.org/@naisys/hub-protocol/-/hub-protocol-3.0.0-beta.39.tgz",
|
|
227
|
+
"integrity": "sha512-zYBhBSVuN/RlEiMpY4ke2J9SwL5Nyd4VNZmP2ZH7hXiL3bDlp8K3odZQqfd1kIUKidnhNzqEH4H+CLOwB4kJPA==",
|
|
228
228
|
"dependencies": {
|
|
229
|
-
"@naisys/common": "3.0.0-beta.
|
|
229
|
+
"@naisys/common": "3.0.0-beta.39",
|
|
230
230
|
"zod": "^4.3.6"
|
|
231
231
|
}
|
|
232
232
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naisys/hub",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.39",
|
|
4
4
|
"description": "NAISYS Hub - Adds persistence and multi-instance coordination to NAISYS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/naisysHub.js",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"dev": "tsx watch src/naisysHub.ts",
|
|
18
18
|
"start": "node dist/naisysHub.js",
|
|
19
19
|
"build": "tsc",
|
|
20
|
+
"test": "vitest run",
|
|
20
21
|
"type-check": "tsc --noEmit",
|
|
21
22
|
"npm:publish:dryrun": "npm publish --dry-run",
|
|
22
23
|
"npm:publish": "npm publish --access public"
|
|
@@ -31,7 +32,7 @@
|
|
|
31
32
|
"!dist/**/*.d.ts.map"
|
|
32
33
|
],
|
|
33
34
|
"peerDependencies": {
|
|
34
|
-
"@naisys/supervisor": "3.0.0-beta.
|
|
35
|
+
"@naisys/supervisor": "3.0.0-beta.39"
|
|
35
36
|
},
|
|
36
37
|
"peerDependenciesMeta": {
|
|
37
38
|
"@naisys/supervisor": {
|
|
@@ -39,10 +40,10 @@
|
|
|
39
40
|
}
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
|
42
|
-
"@naisys/common": "3.0.0-beta.
|
|
43
|
-
"@naisys/common-node": "3.0.0-beta.
|
|
44
|
-
"@naisys/hub-database": "3.0.0-beta.
|
|
45
|
-
"@naisys/hub-protocol": "3.0.0-beta.
|
|
43
|
+
"@naisys/common": "3.0.0-beta.39",
|
|
44
|
+
"@naisys/common-node": "3.0.0-beta.39",
|
|
45
|
+
"@naisys/hub-database": "3.0.0-beta.39",
|
|
46
|
+
"@naisys/hub-protocol": "3.0.0-beta.39",
|
|
46
47
|
"commander": "^14.0.3",
|
|
47
48
|
"dotenv": "^17.3.1",
|
|
48
49
|
"fastify": "^5.8.2",
|
|
@@ -51,7 +52,8 @@
|
|
|
51
52
|
"devDependencies": {
|
|
52
53
|
"@types/node": "^25.5.0",
|
|
53
54
|
"tsx": "^4.21.0",
|
|
54
|
-
"typescript": "^5.9.3"
|
|
55
|
+
"typescript": "^5.9.3",
|
|
56
|
+
"vitest": "^4.1.0"
|
|
55
57
|
},
|
|
56
58
|
"engines": {
|
|
57
59
|
"node": ">=22.0.0"
|