@hivemagnus/magnus-server 0.0.0-stage → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (356) hide show
  1. package/.env.example +171 -0
  2. package/LICENSE +202 -0
  3. package/README.md +169 -2
  4. package/agent-version.json +1 -0
  5. package/dist/ai/HEARTBEAT.md +56 -0
  6. package/dist/ai/IDENTITY.md +54 -0
  7. package/dist/ai/MEMORY.md +40 -0
  8. package/dist/ai/TOOLS.md +538 -0
  9. package/dist/ai/USER.md +32 -0
  10. package/dist/cli/args.js +62 -0
  11. package/dist/cli/env-catalog.js +131 -0
  12. package/dist/cli/preflight.js +89 -0
  13. package/dist/cli.js +175 -0
  14. package/dist/config/cors.js +16 -0
  15. package/dist/config/env.js +392 -0
  16. package/dist/config/features.js +16 -0
  17. package/dist/config/index.js +3 -0
  18. package/dist/config/listener-security.js +38 -0
  19. package/dist/config/version.js +31 -0
  20. package/dist/db/client.js +44 -0
  21. package/dist/db/generated/prisma/browser.js +18 -0
  22. package/dist/db/generated/prisma/client.js +35 -0
  23. package/dist/db/generated/prisma/commonInputTypes.js +11 -0
  24. package/dist/db/generated/prisma/enums.js +42 -0
  25. package/dist/db/generated/prisma/internal/class.js +50 -0
  26. package/dist/db/generated/prisma/internal/prismaNamespace.js +1598 -0
  27. package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +1569 -0
  28. package/dist/db/generated/prisma/models/A2ATask.js +2 -0
  29. package/dist/db/generated/prisma/models/AdmissionFence.js +2 -0
  30. package/dist/db/generated/prisma/models/Agent.js +2 -0
  31. package/dist/db/generated/prisma/models/AiConversation.js +2 -0
  32. package/dist/db/generated/prisma/models/AiDashboard.js +2 -0
  33. package/dist/db/generated/prisma/models/AiMemory.js +2 -0
  34. package/dist/db/generated/prisma/models/AiMessage.js +2 -0
  35. package/dist/db/generated/prisma/models/AiScheduledOp.js +2 -0
  36. package/dist/db/generated/prisma/models/AlertConditionState.js +2 -0
  37. package/dist/db/generated/prisma/models/AlertRule.js +2 -0
  38. package/dist/db/generated/prisma/models/AlertViolation.js +2 -0
  39. package/dist/db/generated/prisma/models/AnomalyEvent.js +2 -0
  40. package/dist/db/generated/prisma/models/ApplicationObservation.js +2 -0
  41. package/dist/db/generated/prisma/models/ArdentInstallation.js +2 -0
  42. package/dist/db/generated/prisma/models/ArdentProfile.js +2 -0
  43. package/dist/db/generated/prisma/models/ArdentScopeEnrollment.js +2 -0
  44. package/dist/db/generated/prisma/models/CommandResourceFence.js +2 -0
  45. package/dist/db/generated/prisma/models/CommandResourceLease.js +2 -0
  46. package/dist/db/generated/prisma/models/CommandResult.js +2 -0
  47. package/dist/db/generated/prisma/models/CronJob.js +2 -0
  48. package/dist/db/generated/prisma/models/CronJobExecution.js +2 -0
  49. package/dist/db/generated/prisma/models/DeployJob.js +2 -0
  50. package/dist/db/generated/prisma/models/DriftScan.js +2 -0
  51. package/dist/db/generated/prisma/models/EngineeringMetricSample.js +2 -0
  52. package/dist/db/generated/prisma/models/EngineeringRecoveryExercise.js +2 -0
  53. package/dist/db/generated/prisma/models/ExecutionRun.js +2 -0
  54. package/dist/db/generated/prisma/models/FilePatchTask.js +2 -0
  55. package/dist/db/generated/prisma/models/GpuSnapshot.js +2 -0
  56. package/dist/db/generated/prisma/models/HealthSnapshot.js +2 -0
  57. package/dist/db/generated/prisma/models/HermesCoreRollout.js +2 -0
  58. package/dist/db/generated/prisma/models/HermesCoreRolloutHost.js +2 -0
  59. package/dist/db/generated/prisma/models/HermesCredentialPool.js +2 -0
  60. package/dist/db/generated/prisma/models/HermesCredentialPoolAttachment.js +2 -0
  61. package/dist/db/generated/prisma/models/HermesCronJob.js +2 -0
  62. package/dist/db/generated/prisma/models/HermesHook.js +2 -0
  63. package/dist/db/generated/prisma/models/HermesInstallation.js +2 -0
  64. package/dist/db/generated/prisma/models/HermesMcpServer.js +2 -0
  65. package/dist/db/generated/prisma/models/HermesMcpServerAttachment.js +2 -0
  66. package/dist/db/generated/prisma/models/HermesMessagingBinding.js +2 -0
  67. package/dist/db/generated/prisma/models/HermesOperationJob.js +2 -0
  68. package/dist/db/generated/prisma/models/HermesPluginInstall.js +2 -0
  69. package/dist/db/generated/prisma/models/HermesProfile.js +2 -0
  70. package/dist/db/generated/prisma/models/HermesProviderConfig.js +2 -0
  71. package/dist/db/generated/prisma/models/HermesRegistryClient.js +2 -0
  72. package/dist/db/generated/prisma/models/HermesRegistryInventory.js +2 -0
  73. package/dist/db/generated/prisma/models/HermesRegistryReceipt.js +2 -0
  74. package/dist/db/generated/prisma/models/HermesRegistryRevision.js +2 -0
  75. package/dist/db/generated/prisma/models/HermesRegistryTarget.js +2 -0
  76. package/dist/db/generated/prisma/models/HermesRole.js +2 -0
  77. package/dist/db/generated/prisma/models/HermesRoleApplication.js +2 -0
  78. package/dist/db/generated/prisma/models/HermesSkillAssignment.js +2 -0
  79. package/dist/db/generated/prisma/models/HermesToolsetPolicy.js +2 -0
  80. package/dist/db/generated/prisma/models/IncidentLesson.js +2 -0
  81. package/dist/db/generated/prisma/models/LibraryCategory.js +2 -0
  82. package/dist/db/generated/prisma/models/LibraryFile.js +2 -0
  83. package/dist/db/generated/prisma/models/LogEntry.js +2 -0
  84. package/dist/db/generated/prisma/models/MagnusReleaseCanary.js +2 -0
  85. package/dist/db/generated/prisma/models/MaintenanceWindow.js +2 -0
  86. package/dist/db/generated/prisma/models/ManagedCronDefinition.js +2 -0
  87. package/dist/db/generated/prisma/models/ManagedCronOccurrence.js +2 -0
  88. package/dist/db/generated/prisma/models/Node.js +2 -0
  89. package/dist/db/generated/prisma/models/Notification.js +2 -0
  90. package/dist/db/generated/prisma/models/NotificationPreference.js +2 -0
  91. package/dist/db/generated/prisma/models/RemediationExecution.js +2 -0
  92. package/dist/db/generated/prisma/models/RemediationRule.js +2 -0
  93. package/dist/db/generated/prisma/models/RemoteCommand.js +2 -0
  94. package/dist/db/generated/prisma/models/ResourceDrainParticipant.js +2 -0
  95. package/dist/db/generated/prisma/models/ResourceEnrollment.js +2 -0
  96. package/dist/db/generated/prisma/models/ResourceParticipantWork.js +2 -0
  97. package/dist/db/generated/prisma/models/Role.js +2 -0
  98. package/dist/db/generated/prisma/models/RoleApplication.js +2 -0
  99. package/dist/db/generated/prisma/models/RolloutTarget.js +2 -0
  100. package/dist/db/generated/prisma/models/SecurityExportOutbox.js +2 -0
  101. package/dist/db/generated/prisma/models/SecurityFusionAction.js +2 -0
  102. package/dist/db/generated/prisma/models/SecurityFusionActionApproval.js +2 -0
  103. package/dist/db/generated/prisma/models/SecurityFusionCase.js +2 -0
  104. package/dist/db/generated/prisma/models/SecurityFusionCaseActivity.js +2 -0
  105. package/dist/db/generated/prisma/models/SecurityFusionCaseEntity.js +2 -0
  106. package/dist/db/generated/prisma/models/SecurityFusionCaseEvent.js +2 -0
  107. package/dist/db/generated/prisma/models/SecurityFusionCaseRelationship.js +2 -0
  108. package/dist/db/generated/prisma/models/SecurityFusionCollector.js +2 -0
  109. package/dist/db/generated/prisma/models/SecurityFusionCommandOutbox.js +2 -0
  110. package/dist/db/generated/prisma/models/SecurityFusionDeletionReceipt.js +2 -0
  111. package/dist/db/generated/prisma/models/SecurityFusionDelivery.js +2 -0
  112. package/dist/db/generated/prisma/models/SecurityFusionEntity.js +2 -0
  113. package/dist/db/generated/prisma/models/SecurityFusionEntityAlias.js +2 -0
  114. package/dist/db/generated/prisma/models/SecurityFusionEvent.js +2 -0
  115. package/dist/db/generated/prisma/models/SecurityFusionEventEntity.js +2 -0
  116. package/dist/db/generated/prisma/models/SecurityFusionEvidence.js +2 -0
  117. package/dist/db/generated/prisma/models/SecurityFusionInvestigation.js +2 -0
  118. package/dist/db/generated/prisma/models/SecurityFusionNonce.js +2 -0
  119. package/dist/db/generated/prisma/models/SecurityFusionReceiptChainHead.js +2 -0
  120. package/dist/db/generated/prisma/models/SecurityFusionRuleVersion.js +2 -0
  121. package/dist/db/generated/prisma/models/SecurityFusionSourceState.js +2 -0
  122. package/dist/db/generated/prisma/models/SecurityServiceState.js +2 -0
  123. package/dist/db/generated/prisma/models/SharedResourceFence.js +2 -0
  124. package/dist/db/generated/prisma/models/SharedResourceLease.js +2 -0
  125. package/dist/db/generated/prisma/models/SharedResourceLeaseResource.js +2 -0
  126. package/dist/db/generated/prisma/models/SkillInstallation.js +2 -0
  127. package/dist/db/generated/prisma/models/SkillRegistryEntry.js +2 -0
  128. package/dist/db/generated/prisma/models/SkillTag.js +2 -0
  129. package/dist/db/generated/prisma/models/SkillVersion.js +2 -0
  130. package/dist/db/generated/prisma/models/TeamApprovalDecision.js +2 -0
  131. package/dist/db/generated/prisma/models/User.js +2 -0
  132. package/dist/db/generated/prisma/models/WebhookDelivery.js +2 -0
  133. package/dist/db/generated/prisma/models/WebhookEndpoint.js +2 -0
  134. package/dist/db/generated/prisma/models.js +2 -0
  135. package/dist/db/index.js +3 -0
  136. package/dist/db/read-retry.js +35 -0
  137. package/dist/db/seed.js +348 -0
  138. package/dist/index.js +162 -0
  139. package/dist/middleware/auth.js +361 -0
  140. package/dist/middleware/body-limit.js +14 -0
  141. package/dist/middleware/correlation-id.js +13 -0
  142. package/dist/middleware/error-handler.js +28 -0
  143. package/dist/middleware/iam-token.js +21 -0
  144. package/dist/middleware/rate-limit.js +78 -0
  145. package/dist/middleware/request-logger.js +85 -0
  146. package/dist/middleware/security-headers.js +23 -0
  147. package/dist/middleware/tracing.js +120 -0
  148. package/dist/middleware/user-status.js +33 -0
  149. package/dist/openapi/spec.js +2656 -0
  150. package/dist/queue/redis.js +20 -0
  151. package/dist/queue/requeue.js +27 -0
  152. package/dist/queue/workers.js +342 -0
  153. package/dist/routes/ai-schedule.js +73 -0
  154. package/dist/routes/alerts.js +79 -0
  155. package/dist/routes/anomalies.js +101 -0
  156. package/dist/routes/ardent.js +82 -0
  157. package/dist/routes/assistant.js +417 -0
  158. package/dist/routes/audit.js +138 -0
  159. package/dist/routes/codex.js +65 -0
  160. package/dist/routes/commands.js +226 -0
  161. package/dist/routes/credentials.js +58 -0
  162. package/dist/routes/cron.js +292 -0
  163. package/dist/routes/dependencies.js +49 -0
  164. package/dist/routes/deploy.js +18 -0
  165. package/dist/routes/discovery.js +168 -0
  166. package/dist/routes/docs.js +24 -0
  167. package/dist/routes/drift.js +126 -0
  168. package/dist/routes/events-stream.js +87 -0
  169. package/dist/routes/files.js +214 -0
  170. package/dist/routes/fleet-checks.js +21 -0
  171. package/dist/routes/health.js +133 -0
  172. package/dist/routes/heartbeat.js +545 -0
  173. package/dist/routes/hermes-registry.js +278 -0
  174. package/dist/routes/hermes.js +61 -0
  175. package/dist/routes/library.js +402 -0
  176. package/dist/routes/logs.js +172 -0
  177. package/dist/routes/managed-cron.js +23 -0
  178. package/dist/routes/mcp.js +39 -0
  179. package/dist/routes/metrics.js +187 -0
  180. package/dist/routes/nodes.js +951 -0
  181. package/dist/routes/notifications.js +130 -0
  182. package/dist/routes/ops.js +258 -0
  183. package/dist/routes/orchestra.js +430 -0
  184. package/dist/routes/predictions.js +23 -0
  185. package/dist/routes/profiling.js +68 -0
  186. package/dist/routes/quick-command.js +72 -0
  187. package/dist/routes/remediation.js +111 -0
  188. package/dist/routes/resource-leases.js +297 -0
  189. package/dist/routes/roles.js +370 -0
  190. package/dist/routes/rollout.js +124 -0
  191. package/dist/routes/scan.js +116 -0
  192. package/dist/routes/security-backup.js +58 -0
  193. package/dist/routes/security-fusion.js +605 -0
  194. package/dist/routes/security-integration.js +71 -0
  195. package/dist/routes/self-update.js +129 -0
  196. package/dist/routes/shared-resource-leases.js +78 -0
  197. package/dist/routes/skills.js +485 -0
  198. package/dist/routes/stats.js +96 -0
  199. package/dist/routes/team-approvals.js +23 -0
  200. package/dist/routes/terminal-ticket.js +15 -0
  201. package/dist/routes/terminal.js +17 -0
  202. package/dist/routes/topology.js +37 -0
  203. package/dist/routes/users.js +219 -0
  204. package/dist/routes/webhooks.js +137 -0
  205. package/dist/schemas/command-reconciliation.js +14 -0
  206. package/dist/schemas/commands.js +51 -0
  207. package/dist/schemas/cron.js +33 -0
  208. package/dist/schemas/deploy.js +20 -0
  209. package/dist/schemas/files.js +14 -0
  210. package/dist/schemas/hermes.js +69 -0
  211. package/dist/schemas/index.js +11 -0
  212. package/dist/schemas/library.js +48 -0
  213. package/dist/schemas/logs.js +35 -0
  214. package/dist/schemas/managed-cron.js +127 -0
  215. package/dist/schemas/node-responses.js +87 -0
  216. package/dist/schemas/node-session-receipt.js +84 -0
  217. package/dist/schemas/nodes.js +36 -0
  218. package/dist/schemas/orchestra.js +118 -0
  219. package/dist/schemas/resource-participant-reconciliation.js +99 -0
  220. package/dist/schemas/roles.js +54 -0
  221. package/dist/schemas/shared-resource-lease.js +132 -0
  222. package/dist/schemas/skills.js +44 -0
  223. package/dist/schemas/users.js +28 -0
  224. package/dist/scripts/run-disposable-recovery-exercise.js +39 -0
  225. package/dist/sdk/index.js +2 -0
  226. package/dist/sdk/node-api-client.js +474 -0
  227. package/dist/security/approval-assertion.js +66 -0
  228. package/dist/security/command-completion-registry.js +35 -0
  229. package/dist/security/command-request.js +12 -0
  230. package/dist/security/command-result-signature.js +34 -0
  231. package/dist/security/managed-http.js +45 -0
  232. package/dist/security/native-completion-registry.js +44 -0
  233. package/dist/server.js +178 -0
  234. package/dist/services/admission-fence.js +71 -0
  235. package/dist/services/ai-assistant-service.js +2523 -0
  236. package/dist/services/ai-correlation-service.js +225 -0
  237. package/dist/services/ai-export-service.js +141 -0
  238. package/dist/services/ai-insights-service.js +219 -0
  239. package/dist/services/ai-schedule-service.js +145 -0
  240. package/dist/services/ai-scratchpad-service.js +222 -0
  241. package/dist/services/ai-tools.js +1343 -0
  242. package/dist/services/alert-service.js +313 -0
  243. package/dist/services/anomaly-detector.js +419 -0
  244. package/dist/services/api-local-work.js +79 -0
  245. package/dist/services/application-probes.js +309 -0
  246. package/dist/services/ardent-scope-enrollment.js +123 -0
  247. package/dist/services/ardent-service.js +86 -0
  248. package/dist/services/audit-service.js +308 -0
  249. package/dist/services/codex-service.js +209 -0
  250. package/dist/services/command-lifecycle.js +60 -0
  251. package/dist/services/command-resource-leases.js +305 -0
  252. package/dist/services/control-host-node.js +242 -0
  253. package/dist/services/credential-rotation-service.js +294 -0
  254. package/dist/services/dependency-service.js +145 -0
  255. package/dist/services/disposable-recovery-runner.js +595 -0
  256. package/dist/services/docker-group-health.js +121 -0
  257. package/dist/services/engineering-assurance.js +492 -0
  258. package/dist/services/engineering-evidence-ledger.js +94 -0
  259. package/dist/services/engineering-metric-evidence.js +26 -0
  260. package/dist/services/event-bus.js +227 -0
  261. package/dist/services/fleet-check-log.js +102 -0
  262. package/dist/services/forecast-service.js +154 -0
  263. package/dist/services/health-retention.js +175 -0
  264. package/dist/services/heartbeat-stream.js +63 -0
  265. package/dist/services/hermes-catalog-import.js +62 -0
  266. package/dist/services/hermes-cli-parser.js +671 -0
  267. package/dist/services/hermes-core-rollout.js +90 -0
  268. package/dist/services/hermes-dashboard-endpoint.js +13 -0
  269. package/dist/services/hermes-plugin-source.js +149 -0
  270. package/dist/services/hermes-registry-service.js +258 -0
  271. package/dist/services/hermes-service.js +566 -0
  272. package/dist/services/hivemem-client.js +85 -0
  273. package/dist/services/host-local-writer.js +82 -0
  274. package/dist/services/incident-evidence.js +110 -0
  275. package/dist/services/incident-lessons.js +186 -0
  276. package/dist/services/llm-providers/anthropic.js +143 -0
  277. package/dist/services/llm-providers/index.js +7 -0
  278. package/dist/services/llm-providers/llama-cpp.js +237 -0
  279. package/dist/services/llm-providers/ollama.js +292 -0
  280. package/dist/services/llm-providers/openai-compatible.js +200 -0
  281. package/dist/services/llm-providers/provider-error.js +36 -0
  282. package/dist/services/llm-providers/registry.js +45 -0
  283. package/dist/services/llm-providers/types.js +2 -0
  284. package/dist/services/log-emitter.js +19 -0
  285. package/dist/services/log-retention.js +66 -0
  286. package/dist/services/maintenance-service.js +29 -0
  287. package/dist/services/managed-cron.js +493 -0
  288. package/dist/services/managed-ssh-admission.js +68 -0
  289. package/dist/services/monitor-pulse.js +10 -0
  290. package/dist/services/node-issue-monitor.js +71 -0
  291. package/dist/services/node-service.js +35 -0
  292. package/dist/services/notification-retention.js +51 -0
  293. package/dist/services/notification-service.js +94 -0
  294. package/dist/services/operational-observations.js +112 -0
  295. package/dist/services/operations-config.js +67 -0
  296. package/dist/services/ops-service.js +491 -0
  297. package/dist/services/orchestra-extras-service.js +370 -0
  298. package/dist/services/orchestra-resources-service.js +530 -0
  299. package/dist/services/os-scheduler-writer.js +73 -0
  300. package/dist/services/password-service.js +22 -0
  301. package/dist/services/prediction-service.js +284 -0
  302. package/dist/services/recovery-exercises.js +682 -0
  303. package/dist/services/remediation-service.js +325 -0
  304. package/dist/services/resource-enrollment.js +433 -0
  305. package/dist/services/resource-node-lock.js +18 -0
  306. package/dist/services/resource-participant-work.js +302 -0
  307. package/dist/services/role-service.js +388 -0
  308. package/dist/services/rollout-service.js +440 -0
  309. package/dist/services/security-export.js +185 -0
  310. package/dist/services/security-fusion-actions.js +22 -0
  311. package/dist/services/security-fusion-approvals.js +83 -0
  312. package/dist/services/security-fusion-evidence-integrity.js +144 -0
  313. package/dist/services/security-fusion-ingest.js +101 -0
  314. package/dist/services/security-fusion-outbox.js +185 -0
  315. package/dist/services/security-fusion-receipt-chain.js +134 -0
  316. package/dist/services/security-fusion-service.js +751 -0
  317. package/dist/services/security-fusion-verification.js +50 -0
  318. package/dist/services/self-update-service.js +200 -0
  319. package/dist/services/shared-resource-leases.js +413 -0
  320. package/dist/services/skill-publication-bundle.js +90 -0
  321. package/dist/services/skill-publication-settlement.js +36 -0
  322. package/dist/services/skill-publication-state.js +40 -0
  323. package/dist/services/skill-registry-service.js +327 -0
  324. package/dist/services/skill-service.js +148 -0
  325. package/dist/services/ssh-exec-service.js +139 -0
  326. package/dist/services/ssh-host-verification.js +27 -0
  327. package/dist/services/tailscale-node-key-service.js +75 -0
  328. package/dist/services/team-approvals.js +118 -0
  329. package/dist/services/topology-service.js +138 -0
  330. package/dist/services/transfer-authority-snapshot.js +151 -0
  331. package/dist/services/webhook-credential-migration.js +97 -0
  332. package/dist/services/webhook-security.js +242 -0
  333. package/dist/services/webhook-service.js +494 -0
  334. package/dist/types/auth.js +3 -0
  335. package/dist/types/index.js +3 -0
  336. package/dist/types/library.js +3 -0
  337. package/dist/types/notifications.js +3 -0
  338. package/dist/types/roles.js +3 -0
  339. package/dist/types/shared-contracts.js +3 -0
  340. package/dist/utils/env.js +141 -0
  341. package/dist/utils/heartbeat-peer.js +19 -0
  342. package/dist/utils/idempotency.js +45 -0
  343. package/dist/utils/ip.js +25 -0
  344. package/dist/utils/json-fields.js +94 -0
  345. package/dist/utils/local-tls.js +39 -0
  346. package/dist/utils/magnus-home.js +15 -0
  347. package/dist/utils/node-url.js +59 -0
  348. package/dist/utils/redact.js +26 -0
  349. package/dist/utils/safe-equal.js +13 -0
  350. package/dist/utils/sentinel-nodes.js +16 -0
  351. package/dist/utils/validate.js +41 -0
  352. package/package.json +119 -4
  353. package/prisma/migrations/20261031000000_baseline/migration.sql +2935 -0
  354. package/prisma/migrations/migration_lock.toml +3 -0
  355. package/prisma/schema.prisma +2185 -0
  356. package/prisma.config.ts +14 -0
@@ -0,0 +1,419 @@
1
+ /**
2
+ * anomaly-detector.ts — Statistical anomaly detection using Z-score analysis.
3
+ *
4
+ * For each node+metric, maintains a rolling window of health samples (last 7 days)
5
+ * and flags unusually high values. Statistical deviation identifies an anomaly;
6
+ * operational pressure determines whether it is warning or critical.
7
+ *
8
+ * Resource pressure policy:
9
+ * CPU warning at 80%, critical at 95%, sustained for 15 minutes
10
+ * Disk warning at 80%, critical at 95%
11
+ * Memory warning at 85%, critical at 95%, only while swap is in use
12
+ * Swap follows memory pressure; swap usage alone is informational
13
+ */
14
+ import { nanoid } from 'nanoid';
15
+ import { getDb } from '../db/index.js';
16
+ import { createNotification } from './notification-service.js';
17
+ import { eventBus, EventChannels } from './event-bus.js';
18
+ import { evaluateRemediation } from './remediation-service.js';
19
+ // ── Configuration ──
20
+ const Z_SCORE_THRESHOLD = Number(process.env['ANOMALY_Z_THRESHOLD'] ?? '4');
21
+ const BASELINE_DAYS = Number(process.env['ANOMALY_BASELINE_DAYS'] ?? '7');
22
+ const BASELINE_CACHE_MS = Number(process.env['ANOMALY_BASELINE_CACHE_MS'] ?? 5 * 60_000);
23
+ const CPU_WARNING_PERCENT = Number(process.env['ANOMALY_CPU_WARNING_PERCENT'] ?? '80');
24
+ const CPU_CRITICAL_PERCENT = Number(process.env['ANOMALY_CPU_CRITICAL_PERCENT'] ?? '95');
25
+ const CPU_SUSTAINED_MINUTES = Number(process.env['ANOMALY_CPU_SUSTAINED_MINUTES'] ?? '15');
26
+ const DISK_WARNING_PERCENT = Number(process.env['ANOMALY_DISK_WARNING_PERCENT'] ?? '80');
27
+ const DISK_CRITICAL_PERCENT = Number(process.env['ANOMALY_DISK_CRITICAL_PERCENT'] ?? '95');
28
+ const MEMORY_WARNING_PERCENT = Number(process.env['ANOMALY_MEMORY_WARNING_PERCENT'] ?? '85');
29
+ const MEMORY_CRITICAL_PERCENT = Number(process.env['ANOMALY_MEMORY_CRITICAL_PERCENT'] ?? '95');
30
+ const MIN_SAMPLES = 10; // Require at least N data points before detecting anomalies
31
+ const MAX_CPU_SAMPLE_GAP_MS = 90 * 1000;
32
+ const baselineCache = new Map();
33
+ // ── Core detection ──
34
+ /**
35
+ * Compute z-score for a single metric value against its historical baseline.
36
+ */
37
+ export function detectAnomaly(current, history) {
38
+ if (history.length < MIN_SAMPLES) {
39
+ return { isAnomaly: false, zScore: 0, mean: current, stdDev: 0, current, severity: 'info' };
40
+ }
41
+ const mean = history.reduce((a, b) => a + b, 0) / history.length;
42
+ const variance = history.reduce((a, b) => a + (b - mean) ** 2, 0) / history.length;
43
+ const stdDev = Math.sqrt(variance);
44
+ // Avoid division by zero — if all historic values are identical, any deviation is anomalous
45
+ const zScore = stdDev === 0 ? (current === mean ? 0 : 999) : (current - mean) / stdDev;
46
+ return {
47
+ // Resource utilization decreasing unexpectedly is not operational pressure.
48
+ isAnomaly: zScore >= Z_SCORE_THRESHOLD,
49
+ zScore: Math.round(zScore * 100) / 100,
50
+ mean: Math.round(mean * 100) / 100,
51
+ stdDev: Math.round(stdDev * 100) / 100,
52
+ current,
53
+ severity: 'info',
54
+ };
55
+ }
56
+ /**
57
+ * Require a continuous trailing run above the threshold. Sparse samples or a
58
+ * single spike cannot promote CPU severity.
59
+ */
60
+ export function hasSustainedCpuPressure(samples, threshold, durationMinutes = CPU_SUSTAINED_MINUTES) {
61
+ if (samples.length < 2)
62
+ return false;
63
+ const ordered = [...samples].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
64
+ const newest = ordered[ordered.length - 1];
65
+ if (!newest || newest.value < threshold)
66
+ return false;
67
+ let oldest = newest;
68
+ for (let i = ordered.length - 2; i >= 0; i--) {
69
+ const sample = ordered[i];
70
+ if (!sample)
71
+ break;
72
+ const gap = oldest.timestamp.getTime() - sample.timestamp.getTime();
73
+ if (gap > MAX_CPU_SAMPLE_GAP_MS || sample.value < threshold)
74
+ break;
75
+ oldest = sample;
76
+ }
77
+ return newest.timestamp.getTime() - oldest.timestamp.getTime() >= durationMinutes * 60 * 1000;
78
+ }
79
+ /** Promote statistical anomalies only when the resource is under real pressure. */
80
+ export function classifyResourceSeverity(metric, current, context = {}) {
81
+ switch (metric) {
82
+ case 'cpu':
83
+ if (hasSustainedCpuPressure(context.cpuSamples ?? [], CPU_CRITICAL_PERCENT))
84
+ return 'critical';
85
+ if (hasSustainedCpuPressure(context.cpuSamples ?? [], CPU_WARNING_PERCENT))
86
+ return 'warning';
87
+ return 'info';
88
+ case 'disk':
89
+ if (current >= DISK_CRITICAL_PERCENT)
90
+ return 'critical';
91
+ if (current >= DISK_WARNING_PERCENT)
92
+ return 'warning';
93
+ return 'info';
94
+ case 'memory':
95
+ case 'swap': {
96
+ const memoryPercent = context.memoryPercent ?? (metric === 'memory' ? current : 0);
97
+ const isSwapping = (context.swapUsedBytes ?? 0) > 0;
98
+ if (!isSwapping)
99
+ return 'info';
100
+ if (memoryPercent >= MEMORY_CRITICAL_PERCENT)
101
+ return 'critical';
102
+ if (memoryPercent >= MEMORY_WARNING_PERCENT)
103
+ return 'warning';
104
+ return 'info';
105
+ }
106
+ default:
107
+ return 'info';
108
+ }
109
+ }
110
+ function extractMetrics(metrics) {
111
+ const result = [];
112
+ if (metrics.cpuUsagePercent !== undefined) {
113
+ result.push({ metric: 'cpu', value: metrics.cpuUsagePercent });
114
+ }
115
+ if (metrics.memoryPercent !== undefined) {
116
+ result.push({ metric: 'memory', value: metrics.memoryPercent });
117
+ }
118
+ // Swap as percentage
119
+ const swapTotal = Number(metrics.swapTotalBytes ?? 0);
120
+ const swapUsed = Number(metrics.swapUsedBytes ?? 0);
121
+ if (swapTotal > 0) {
122
+ result.push({ metric: 'swap', value: (swapUsed / swapTotal) * 100 });
123
+ }
124
+ // Max disk usage
125
+ const disks = metrics.disks ?? [];
126
+ if (disks.length > 0) {
127
+ const maxDisk = Math.max(...disks.map((d) => d.usagePercent ?? 0));
128
+ result.push({ metric: 'disk', value: maxDisk });
129
+ }
130
+ return result;
131
+ }
132
+ // ── Fetch historical baseline for a node+metric ──
133
+ export function buildMetricBaselines(snapshots) {
134
+ const baselines = { cpu: [], memory: [], swap: [], disk: [] };
135
+ for (const snapshot of snapshots) {
136
+ baselines.cpu.push(snapshot.cpuUsagePercent);
137
+ baselines.memory.push(snapshot.memoryPercent);
138
+ const total = Number(snapshot.swapTotalBytes);
139
+ const used = Number(snapshot.swapUsedBytes);
140
+ baselines.swap.push(total > 0 ? (used / total) * 100 : 0);
141
+ try {
142
+ const disks = JSON.parse(snapshot.disks);
143
+ baselines.disk.push(Math.max(0, ...disks.map((disk) => disk.usagePercent ?? 0)));
144
+ }
145
+ catch {
146
+ baselines.disk.push(0);
147
+ }
148
+ }
149
+ return baselines;
150
+ }
151
+ async function getBaselines(nodeId) {
152
+ const now = Date.now();
153
+ const cached = baselineCache.get(nodeId);
154
+ if (cached && cached.expiresAt > now)
155
+ return cached.baselines;
156
+ const db = getDb();
157
+ const since = new Date(now - BASELINE_DAYS * 24 * 60 * 60 * 1000);
158
+ const snapshots = await db.healthSnapshot.findMany({
159
+ where: { nodeId, timestamp: { gte: since } },
160
+ select: {
161
+ cpuUsagePercent: true,
162
+ memoryPercent: true,
163
+ swapTotalBytes: true,
164
+ swapUsedBytes: true,
165
+ disks: true,
166
+ },
167
+ orderBy: { timestamp: 'asc' },
168
+ });
169
+ const baselines = buildMetricBaselines(snapshots);
170
+ baselineCache.set(nodeId, { expiresAt: now + BASELINE_CACHE_MS, baselines });
171
+ return baselines;
172
+ }
173
+ async function getRecentCpuSamples(nodeId) {
174
+ const db = getDb();
175
+ // Include one extra sample interval so a full 15-minute trailing run can be proven.
176
+ const since = new Date(Date.now() - (CPU_SUSTAINED_MINUTES * 60 * 1000 + MAX_CPU_SAMPLE_GAP_MS));
177
+ const snapshots = await db.healthSnapshot.findMany({
178
+ where: { nodeId, timestamp: { gte: since } },
179
+ select: { timestamp: true, cpuUsagePercent: true },
180
+ orderBy: { timestamp: 'asc' },
181
+ });
182
+ return snapshots.map((snapshot) => ({
183
+ timestamp: snapshot.timestamp,
184
+ value: snapshot.cpuUsagePercent,
185
+ }));
186
+ }
187
+ // ── Main evaluation entry point ──
188
+ /**
189
+ * Evaluate a node's health metrics for anomalies. Called after storing each heartbeat snapshot.
190
+ */
191
+ export async function evaluateAnomalies(nodeId, nodeName, metrics, healthSnapshotId) {
192
+ const db = getDb();
193
+ const extractions = extractMetrics(metrics);
194
+ const results = [];
195
+ const [baselines, cpuSamples] = await Promise.all([
196
+ getBaselines(nodeId),
197
+ extractions.some(({ metric }) => metric === 'cpu')
198
+ ? getRecentCpuSamples(nodeId)
199
+ : Promise.resolve([]),
200
+ ]);
201
+ for (const { metric, value } of extractions) {
202
+ const history = baselines[metric];
203
+ const result = detectAnomaly(value, history);
204
+ result.severity = classifyResourceSeverity(metric, value, {
205
+ memoryPercent: metrics.memoryPercent,
206
+ swapUsedBytes: Number(metrics.swapUsedBytes ?? 0),
207
+ cpuSamples,
208
+ });
209
+ results.push(result);
210
+ const existing = await db.anomalyEvent.findFirst({
211
+ where: { nodeId, metric, resolved: false },
212
+ orderBy: { lastSeenAt: 'desc' },
213
+ });
214
+ if (result.isAnomaly) {
215
+ let anomalyId;
216
+ if (existing) {
217
+ // Roll up — update the existing anomaly event
218
+ anomalyId = existing.id;
219
+ await db.anomalyEvent.update({
220
+ where: { id: existing.id },
221
+ data: {
222
+ value: result.current,
223
+ zScore: result.zScore,
224
+ mean: result.mean,
225
+ stdDev: result.stdDev,
226
+ severity: result.severity,
227
+ ...(healthSnapshotId ? { healthSnapshotId } : {}),
228
+ lastSeenAt: new Date(),
229
+ occurrenceCount: { increment: 1 },
230
+ },
231
+ });
232
+ }
233
+ else {
234
+ // Create a new anomaly event
235
+ anomalyId = nanoid();
236
+ await db.anomalyEvent.create({
237
+ data: {
238
+ id: anomalyId,
239
+ nodeId,
240
+ metric,
241
+ value: result.current,
242
+ zScore: result.zScore,
243
+ mean: result.mean,
244
+ stdDev: result.stdDev,
245
+ severity: result.severity,
246
+ ...(healthSnapshotId ? { healthSnapshotId } : {}),
247
+ },
248
+ });
249
+ }
250
+ // Publish to event bus
251
+ await eventBus.publish(EventChannels.ALERT_FIRED, {
252
+ type: 'anomaly',
253
+ nodeId,
254
+ nodeName,
255
+ metric,
256
+ value: result.current,
257
+ zScore: result.zScore,
258
+ severity: result.severity,
259
+ });
260
+ // Create notification for warning+ anomalies
261
+ if (result.severity === 'warning' || result.severity === 'critical') {
262
+ const metricLabel = metric.toUpperCase();
263
+ await createNotification({
264
+ type: `anomaly.${metric}`,
265
+ severity: result.severity,
266
+ title: `Anomaly: ${metricLabel} on ${nodeName} (z=${result.zScore})`,
267
+ message: `Statistical anomaly detected: ${metricLabel} is ${result.current.toFixed(1)}% (mean: ${result.mean.toFixed(1)}%, z-score: ${result.zScore}). This is unusual for this node.`,
268
+ nodeId,
269
+ nodeName,
270
+ metadata: {
271
+ metric,
272
+ value: result.current,
273
+ zScore: result.zScore,
274
+ mean: result.mean,
275
+ stdDev: result.stdDev,
276
+ severity: result.severity,
277
+ },
278
+ });
279
+ }
280
+ // Evaluate auto-remediation rules (fire-and-forget)
281
+ evaluateRemediation({
282
+ id: anomalyId,
283
+ nodeId,
284
+ nodeName,
285
+ metric,
286
+ value: result.current,
287
+ zScore: result.zScore,
288
+ severity: result.severity,
289
+ }).catch((err) => {
290
+ console.error(`[remediation] evaluate error for ${nodeId}/${metric}:`, err instanceof Error ? err.message : err);
291
+ });
292
+ }
293
+ else if (existing) {
294
+ // Close the rollup when the metric returns to its normal range. Previously
295
+ // these events stayed active forever and made one-off spikes look current.
296
+ await db.anomalyEvent.update({
297
+ where: { id: existing.id },
298
+ data: { resolved: true, resolvedAt: new Date() },
299
+ });
300
+ }
301
+ }
302
+ return results;
303
+ }
304
+ // ── Query helpers ──
305
+ export async function listAnomalyEvents(params) {
306
+ const db = getDb();
307
+ const page = params.page ?? 1;
308
+ const pageSize = Math.min(params.pageSize ?? 50, 100);
309
+ const skip = (page - 1) * pageSize;
310
+ const where = {};
311
+ if (params.nodeId)
312
+ where['nodeId'] = params.nodeId;
313
+ if (params.metric)
314
+ where['metric'] = params.metric;
315
+ if (params.severity)
316
+ where['severity'] = params.severity;
317
+ if (params.resolved !== undefined)
318
+ where['resolved'] = params.resolved;
319
+ const [anomalies, total] = await Promise.all([
320
+ db.anomalyEvent.findMany({
321
+ where,
322
+ orderBy: { createdAt: 'desc' },
323
+ skip,
324
+ take: pageSize,
325
+ include: { node: { select: { name: true, hostname: true } } },
326
+ }),
327
+ db.anomalyEvent.count({ where }),
328
+ ]);
329
+ return { anomalies, total };
330
+ }
331
+ export async function getNodeAnomalySummary(nodeId) {
332
+ const db = getDb();
333
+ const [total, active, recent] = await Promise.all([
334
+ db.anomalyEvent.count({ where: { nodeId } }),
335
+ db.anomalyEvent.count({ where: { nodeId, resolved: false } }),
336
+ db.anomalyEvent.findMany({
337
+ where: { nodeId },
338
+ orderBy: { createdAt: 'desc' },
339
+ take: 10,
340
+ }),
341
+ ]);
342
+ // Compute by-metric breakdown
343
+ const byMetricRaw = await db.anomalyEvent.groupBy({
344
+ by: ['metric'],
345
+ where: { nodeId, resolved: false },
346
+ _count: true,
347
+ });
348
+ const byMetric = {};
349
+ for (const row of byMetricRaw) {
350
+ byMetric[row.metric] = row._count;
351
+ }
352
+ return { total, active, byMetric, recent };
353
+ }
354
+ export async function resolveAnomaly(id) {
355
+ const db = getDb();
356
+ await db.anomalyEvent.update({
357
+ where: { id },
358
+ data: { resolved: true, resolvedAt: new Date() },
359
+ });
360
+ }
361
+ export async function resolveNodeAnomalies(nodeId, metric) {
362
+ const db = getDb();
363
+ const where = { nodeId, resolved: false };
364
+ if (metric)
365
+ where['metric'] = metric;
366
+ const result = await db.anomalyEvent.updateMany({
367
+ where,
368
+ data: { resolved: true, resolvedAt: new Date() },
369
+ });
370
+ return result.count;
371
+ }
372
+ /**
373
+ * Get a fleet-wide anomaly summary for the AI heartbeat.
374
+ */
375
+ export async function getFleetAnomalySummary() {
376
+ const db = getDb();
377
+ const activeAnomalies = await db.anomalyEvent.findMany({
378
+ where: { resolved: false },
379
+ include: { node: { select: { name: true } } },
380
+ });
381
+ const totalActive = activeAnomalies.length;
382
+ // Group by node
383
+ const nodeMap = new Map();
384
+ const severityMap = { info: 0, warning: 0, critical: 0 };
385
+ for (const a of activeAnomalies) {
386
+ const entry = nodeMap.get(a.nodeId) ?? {
387
+ nodeName: a.node.name,
388
+ count: 0,
389
+ metrics: new Set(),
390
+ };
391
+ entry.count++;
392
+ entry.metrics.add(a.metric);
393
+ nodeMap.set(a.nodeId, entry);
394
+ severityMap[a.severity] = (severityMap[a.severity] ?? 0) + 1;
395
+ }
396
+ const byNode = Array.from(nodeMap.entries()).map(([nodeId, v]) => ({
397
+ nodeId,
398
+ nodeName: v.nodeName,
399
+ count: v.count,
400
+ metrics: Array.from(v.metrics),
401
+ }));
402
+ return { totalActive, byNode, bySeverity: severityMap };
403
+ }
404
+ /**
405
+ * Purge old resolved anomalies beyond the retention period.
406
+ * Returns the number of deleted rows.
407
+ */
408
+ export async function purgeResolvedAnomalies(retentionDays = 30) {
409
+ const db = getDb();
410
+ const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
411
+ const result = await db.anomalyEvent.deleteMany({
412
+ where: {
413
+ resolved: true,
414
+ resolvedAt: { lt: cutoff },
415
+ },
416
+ });
417
+ return result.count;
418
+ }
419
+ //# sourceMappingURL=anomaly-detector.js.map
@@ -0,0 +1,79 @@
1
+ /** Local API maintenance has the same durable drain boundary as remote SSH.
2
+ * A verified loopback TLS probe selects inventory identity, never caller input.
3
+ * Process exit/exec completion cannot prove descendant termination.
4
+ */
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { getDb } from '../db/index.js';
7
+ import { HttpError } from '../middleware/error-handler.js';
8
+ import { probeLocalAgent } from './control-host-node.js';
9
+ import { apiParticipantWork } from './resource-participant-work.js';
10
+ const ownerId = randomUUID();
11
+ const KINDS = ['self-update-check', 'self-update-apply', 'self-update-restart'];
12
+ /** Only this server-created error may carry an operation receipt to HTTP clients. */
13
+ export class ApiLocalWorkError extends HttpError {
14
+ workId;
15
+ constructor(workId, error) {
16
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(workId))
17
+ throw new TypeError('A durable work UUID is required');
18
+ super(error instanceof HttpError ? error.status : 500, error instanceof HttpError ? error.code : 'LOCAL_WORK_FAILED', error instanceof Error ? error.message : 'Local maintenance failed; inspect its durable work receipt');
19
+ this.name = 'ApiLocalWorkError';
20
+ this.workId = workId;
21
+ this.cause = error;
22
+ }
23
+ }
24
+ async function localHost() {
25
+ const probe = await probeLocalAgent();
26
+ const host = probe?.verifiedTlsHostname;
27
+ if (!host)
28
+ throw new HttpError(503, 'LOCAL_WORK_IDENTITY_UNAVAILABLE', 'Verified local agent TLS identity is required for API maintenance');
29
+ return host;
30
+ }
31
+ export async function reserveApiLocalWork(kind) {
32
+ const host = await localHost();
33
+ const workId = randomUUID();
34
+ const deadline = Date.now() + 60 * 60_000;
35
+ const operationSha256 = createHash('sha256').update(JSON.stringify({ domain: 'magnus.api-local-external.v1',
36
+ host, workId, ownerId, kind, effectBoundary: 'external-unverified' })).digest('hex');
37
+ let reservation;
38
+ try {
39
+ reservation = await apiParticipantWork.reserve({ host, workId, ownerId, kind, operationSha256, deadline });
40
+ }
41
+ catch (error) {
42
+ // The transaction may have committed before its acknowledgment was lost.
43
+ // A missing receipt is honest; losing the only lookup ID is not recoverable.
44
+ throw new ApiLocalWorkError(workId, error);
45
+ }
46
+ return { workId, async run(operation) {
47
+ let started = false;
48
+ try {
49
+ if (await localHost() !== host)
50
+ throw new HttpError(409, 'LOCAL_WORK_IDENTITY_CHANGED', 'Local API maintenance target changed');
51
+ await apiParticipantWork.start(workId, { ownerId, epoch: reservation.epoch, barrierId: reservation.barrierId,
52
+ operationSha256, fence: reservation.fence, deadline, requestSha256: reservation.requestSha256, targetHost: host });
53
+ started = true;
54
+ return await operation();
55
+ }
56
+ catch (error) {
57
+ throw new ApiLocalWorkError(workId, error);
58
+ }
59
+ finally {
60
+ // A lost start acknowledgment can leave a running row; finish preserves it
61
+ // as unknown even when the caller did not observe the successful start.
62
+ await apiParticipantWork.finish(workId, { ownerId, disposition: started ? 'unknown' : 'not-started',
63
+ evidenceSha256: createHash('sha256').update(`api-local-observation:${workId}:${started}`).digest('hex') }).catch(() => undefined);
64
+ }
65
+ } };
66
+ }
67
+ export async function withApiLocalWork(kind, operation) {
68
+ const work = await reserveApiLocalWork(kind);
69
+ return { ...await work.run(operation), workId: work.workId };
70
+ }
71
+ export async function apiLocalWorkStatus(workId) {
72
+ const row = await getDb().resourceParticipantWork.findFirst({ where: { workId, participant: 'api', kind: { in: [...KINDS] } },
73
+ select: { workId: true, nodeId: true, kind: true, state: true, epoch: true, barrierId: true,
74
+ startedAt: true, settledAt: true, settlement: true } });
75
+ if (!row)
76
+ throw new HttpError(404, 'LOCAL_WORK_NOT_FOUND', 'API maintenance receipt not found');
77
+ return row;
78
+ }
79
+ //# sourceMappingURL=api-local-work.js.map