@naisys/hub 3.0.0-beta.38 → 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.
@@ -256,6 +256,7 @@ export function createHubAgentService(naisysServer, { hubDb }, logService, heart
256
256
  const sent = naisysServer.sendMessage(targetHostId, event, {
257
257
  userId: parsed.userId,
258
258
  runId: parsed.runId,
259
+ subagentId: parsed.subagentId,
259
260
  sessionId: parsed.sessionId,
260
261
  sourceHostId: hostId,
261
262
  }, (response) => {
@@ -300,6 +301,7 @@ export function createHubAgentService(naisysServer, { hubDb }, logService, heart
300
301
  const sent = naisysServer.sendMessage(targetHostId, HubEvents.AGENT_RUN_COMMAND, {
301
302
  userId: parsed.userId,
302
303
  runId: parsed.runId,
304
+ subagentId: parsed.subagentId,
303
305
  sessionId: parsed.sessionId,
304
306
  command: parsed.command,
305
307
  sourceHostId: hostId,
@@ -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,6 +55,19 @@ 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) {
@@ -99,10 +109,11 @@ export function createHubHeartbeatService(naisysServer, { hubDb }, logService) {
99
109
  function pushSessionHeartbeat() {
100
110
  const updates = [];
101
111
  for (const sessions of hostActiveSessions.values()) {
102
- for (const [userId, info] of sessions) {
112
+ for (const info of sessions.values()) {
103
113
  updates.push({
104
- userId,
114
+ userId: info.userId,
105
115
  runId: info.runId,
116
+ subagentId: info.subagentId ?? undefined,
106
117
  sessionId: info.sessionId,
107
118
  lastActive: info.lastActive,
108
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,
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...`);
@@ -79,7 +80,7 @@ export const startHub = async (startupType, startSupervisor, plugins, startupAge
79
80
  // Register hub agent service for agent_start requests routed to target hosts
80
81
  const agentService = createHubAgentService(naisysServer, hubDatabaseService, logService, heartbeatService, sendMailService, hostRegistrar);
81
82
  // Register hub mail service for mail events from NAISYS instances
82
- createHubMailService(naisysServer, hubDatabaseService, logService, heartbeatService, sendMailService, agentService, costService, configService);
83
+ const mailService = createHubMailService(naisysServer, hubDatabaseService, logService, heartbeatService, sendMailService, agentService, costService, configService);
83
84
  /**
84
85
  * There should be no dependency between supervisor and hub
85
86
  * Sharing the same process space is to save 150 mb of node.js runtime memory on small servers
@@ -88,14 +89,16 @@ export const startHub = async (startupType, startSupervisor, plugins, startupAge
88
89
  // Don't import the whole fastify web server module tree unless needed
89
90
  // Use variable to avoid compile-time type dependency on @naisys/supervisor (allows parallel builds)
90
91
  const supervisorModule = "@naisys/supervisor";
91
- const { supervisorPlugin, bootstrapSupervisor } = (await import(supervisorModule));
92
- const resetSuperAdminPasskey = wizardRan
93
- ? 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", {
94
97
  defaultReset: !process.argv.includes("--setup"),
95
98
  })
96
99
  : false;
97
100
  // Bootstrap before plugin register so the operator prompt isn't bounded by pluginTimeout and doesn't interleave with hub connection logs.
98
- await bootstrapSupervisor({ resetSuperAdminPasskey });
101
+ await bootstrapSupervisor({ resetSuperAdminAccount });
99
102
  await fastify.register(supervisorPlugin, {
100
103
  plugins,
101
104
  serverPort,
@@ -109,11 +112,14 @@ export const startHub = async (startupType, startSupervisor, plugins, startupAge
109
112
  logService.disableConsole();
110
113
  }
111
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.
112
117
  async function runShutdown() {
113
118
  try {
119
+ cleanupSupervisor?.();
114
120
  heartbeatService.cleanup();
115
- await io.close();
116
- await fastify.close();
121
+ costService.cleanup();
122
+ mailService.cleanup();
117
123
  }
118
124
  finally {
119
125
  await hubDatabaseService.disconnect();
@@ -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.38",
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.38",
9
+ "version": "3.0.0-beta.39",
10
10
  "dependencies": {
11
- "@naisys/common": "3.0.0-beta.38",
12
- "@naisys/common-node": "3.0.0-beta.38",
13
- "@naisys/hub-database": "3.0.0-beta.38",
14
- "@naisys/hub-protocol": "3.0.0-beta.38",
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.38"
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.38",
193
- "resolved": "https://registry.npmjs.org/@naisys/common/-/common-3.0.0-beta.38.tgz",
194
- "integrity": "sha512-YJIawcV12XNgzQz/QcM2oAYhu4O4V55tHm0JZrEdmBt2GS33VsGIVJqhDtg3sVWvtsDfh8QkK7EjwPEvgj7fzA==",
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.38",
202
- "resolved": "https://registry.npmjs.org/@naisys/common-node/-/common-node-3.0.0-beta.38.tgz",
203
- "integrity": "sha512-dNVvY+gWzBx4I+3chvNl74MgrcxVBFpPB+0acYSl7bcINvhqmbqLMYryR4AwOvU+dc09GDbpgugE6EWIQnn/zA==",
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.38",
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.38",
213
- "resolved": "https://registry.npmjs.org/@naisys/hub-database/-/hub-database-3.0.0-beta.38.tgz",
214
- "integrity": "sha512-h+e50Qjg389ewSKU2jAisexlmajEAK34lMz6FKWd8Hog/PI6v3BYKB/8wgL2HmI78apmHIjh1FRW3NQNF+D8rw==",
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.38",
217
- "@naisys/common-node": "3.0.0-beta.38",
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.38",
226
- "resolved": "https://registry.npmjs.org/@naisys/hub-protocol/-/hub-protocol-3.0.0-beta.38.tgz",
227
- "integrity": "sha512-HLNcDmbdQVDeOxQlohoSrQdz/EXvNKx16BZVEovlwixuGCfINgweOkjtr4qypHO0peHPD0p1ayBa2QAjRrSY5Q==",
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.38",
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.38",
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.38"
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.38",
43
- "@naisys/common-node": "3.0.0-beta.38",
44
- "@naisys/hub-database": "3.0.0-beta.38",
45
- "@naisys/hub-protocol": "3.0.0-beta.38",
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"