@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.
@@ -1,6 +1,29 @@
1
- import { AgentPeekRequestSchema, AgentRunCommandRequestSchema, AgentRunPauseRequestSchema, AgentStartRequestSchema, AgentStopRequestSchema, HubEvents, } from "@naisys/hub-protocol";
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
- if (!(await isAgentEnabled(startUserId))) {
59
- logService.log(`[Hub:Agents] Auto-start: agent ${startUserId} is disabled or archived`);
60
- return false;
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 = naisysServer.sendMessage(bestHostId, HubEvents.AGENT_START, {
68
- startUserId,
69
- }, (response) => {
70
- if (response.success) {
71
- heartbeatService.addStartedAgent(bestHostId, startUserId);
72
- }
73
- else {
74
- logService.error(`[Hub:Agents] Auto-start failed for user ${startUserId}: ${response.error}`);
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 = AgentStartRequestSchema.parse(data);
90
- const requesterUserId = parsed.requesterUserId;
91
- if (!(await isAgentEnabled(parsed.startUserId))) {
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
- if (!requesterUserId) {
107
- ack({
108
- success: false,
109
- error: `Missing requesterUserId in agent_start request for user ${parsed.startUserId}`,
110
- });
111
- return;
112
- }
113
- // Forward the start request to the selected host
114
- const sent = naisysServer.sendMessage(bestHostId, HubEvents.AGENT_START, {
115
- startUserId: parsed.startUserId,
116
- taskDescription: parsed.taskDescription,
117
- sourceHostId: hostId,
118
- }, (response) => {
119
- if (response.success) {
120
- heartbeatService.addStartedAgent(bestHostId, parsed.startUserId);
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 { mimeFromFilename } from "@naisys/common";
2
- import { extractBearerToken } from "@naisys/common-node";
3
- import { createHash, randomBytes } from "crypto";
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({ where: { api_key: apiKey } });
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 > MAX_FILE_SIZE) {
52
+ if (fileSize > MAX_HUB_ATTACHMENT_SIZE) {
53
53
  return reply.code(413).send({
54
- error: `File too large. Max size: ${MAX_FILE_SIZE} bytes`,
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 > MAX_FILE_SIZE) {
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
- // Move to content-addressable path: attachments/hub/<first2>/<next2>/<fullhash>
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: randomBytes(8).toString("base64url").slice(0, 10),
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
- if (!existsSync(attachment.filepath)) {
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
- // Track which users have been suspended due to spend limit overrun
7
- const suspendedByGlobal = new Set();
8
- const suspendedByAgent = new Set();
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 any suspended users still writing costs
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
- if (suspendedByGlobal.has(userId) || suspendedByAgent.has(userId)) {
67
- sendCostControl(userId, false, "Spend limit exceeded");
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.add(userId);
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.add(userId);
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
- // Track active agent user IDs per host from heartbeat data
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
- const activeUserIds = parsed.activeSessions.map((s) => s.userId);
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
- await hubDb.run_session.updateMany({
46
- where: {
47
- user_id: session.userId,
48
- run_id: session.runId,
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 [userId, info] of sessions) {
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
- // 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;
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: newRunId,
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: newRunId,
62
+ runId,
39
63
  sessionId: newSessionId,
40
64
  });
41
65
  pushSessionToSupervisors({
42
66
  userId: parsed.userId,
43
- runId: newRunId,
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
- // Get the max session_id for this user + run
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, promptResetSuperAdminPasskey, runSetupWizard, } from "@naisys/common-node";
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
- // Create Fastify instance (TLS is handled by the reverse proxy)
47
- const fastify = Fastify({ pluginTimeout: 60_000 });
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 { supervisorPlugin, bootstrapSupervisor } = (await import(supervisorModule));
91
- const resetSuperAdminPasskey = wizardRan
92
- ? await promptResetSuperAdminPasskey("Supervisor Setup")
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({ resetSuperAdminPasskey });
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 io.close();
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 { randomBytes, randomUUID } from "crypto";
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
- return next(createConnectError("Invalid access key", "invalid_access_key"));
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
- return next(createConnectError("NAISYS instance registration failed", "registration_failed"));
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
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@naisys/hub",
3
- "version": "3.0.0-beta.37",
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.37",
9
+ "version": "3.0.0-beta.39",
10
10
  "dependencies": {
11
- "@naisys/common": "3.0.0-beta.37",
12
- "@naisys/common-node": "3.0.0-beta.37",
13
- "@naisys/hub-database": "3.0.0-beta.37",
14
- "@naisys/hub-protocol": "3.0.0-beta.37",
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.37"
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.37",
193
- "resolved": "https://registry.npmjs.org/@naisys/common/-/common-3.0.0-beta.37.tgz",
194
- "integrity": "sha512-b0XfCadaPcfewmK9b649WD+ZGB86Uk9BjTrx/tLSgd5Nbx7L6NuzTqTnDFScdeCmtyPk4oUGAzATmXwKTM8bew==",
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.37",
202
- "resolved": "https://registry.npmjs.org/@naisys/common-node/-/common-node-3.0.0-beta.37.tgz",
203
- "integrity": "sha512-V4yyA79G93OSqZ+6l5FWoMqnATE2It78VkGyziNTqcrv2CIf8UDGbds7hHV+bp/0VplNe6BrZ4P7quYvgyCeeQ==",
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.37",
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.37",
213
- "resolved": "https://registry.npmjs.org/@naisys/hub-database/-/hub-database-3.0.0-beta.37.tgz",
214
- "integrity": "sha512-PI8Jj4niCc6agObLWpP+Am8nrNcwAAk77/OmZmUWq1ZiBW+m5C60PFdfBvzLEZX/6G/JOb0VvI9oLpzFD6VRKg==",
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.37",
217
- "@naisys/common-node": "3.0.0-beta.37",
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.37",
226
- "resolved": "https://registry.npmjs.org/@naisys/hub-protocol/-/hub-protocol-3.0.0-beta.37.tgz",
227
- "integrity": "sha512-hJ7kr4WT6TfCmVQFPCfnjC4Sjp7DehuZ4la+z5/MZ3AEm6Ds/WxqcoPOf1F47y6uZA70WhnVHvAt/dci0dO8Ow==",
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.37",
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.37",
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.37"
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.37",
43
- "@naisys/common-node": "3.0.0-beta.37",
44
- "@naisys/hub-database": "3.0.0-beta.37",
45
- "@naisys/hub-protocol": "3.0.0-beta.37",
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"