@calltelemetry/ct-lab-mcp 0.1.0

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 (231) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +309 -0
  3. package/dist/bin/ct-lab-mcp.d.ts +60 -0
  4. package/dist/bin/ct-lab-mcp.d.ts.map +1 -0
  5. package/dist/bin/ct-lab-mcp.js +261 -0
  6. package/dist/bin/ct-lab-mcp.js.map +1 -0
  7. package/dist/bin/proxmox-mcp.d.ts +58 -0
  8. package/dist/bin/proxmox-mcp.d.ts.map +1 -0
  9. package/dist/bin/proxmox-mcp.js +248 -0
  10. package/dist/bin/proxmox-mcp.js.map +1 -0
  11. package/dist/src/appliance/commands.d.ts +33 -0
  12. package/dist/src/appliance/commands.d.ts.map +1 -0
  13. package/dist/src/appliance/commands.js +241 -0
  14. package/dist/src/appliance/commands.js.map +1 -0
  15. package/dist/src/appliance/executor.d.ts +34 -0
  16. package/dist/src/appliance/executor.d.ts.map +1 -0
  17. package/dist/src/appliance/executor.js +214 -0
  18. package/dist/src/appliance/executor.js.map +1 -0
  19. package/dist/src/appliance/index.d.ts +8 -0
  20. package/dist/src/appliance/index.d.ts.map +1 -0
  21. package/dist/src/appliance/index.js +8 -0
  22. package/dist/src/appliance/index.js.map +1 -0
  23. package/dist/src/appliance/mock.d.ts +66 -0
  24. package/dist/src/appliance/mock.d.ts.map +1 -0
  25. package/dist/src/appliance/mock.js +339 -0
  26. package/dist/src/appliance/mock.js.map +1 -0
  27. package/dist/src/appliance/types.d.ts +83 -0
  28. package/dist/src/appliance/types.d.ts.map +1 -0
  29. package/dist/src/appliance/types.js +58 -0
  30. package/dist/src/appliance/types.js.map +1 -0
  31. package/dist/src/credentials/doppler.d.ts +25 -0
  32. package/dist/src/credentials/doppler.d.ts.map +1 -0
  33. package/dist/src/credentials/doppler.js +68 -0
  34. package/dist/src/credentials/doppler.js.map +1 -0
  35. package/dist/src/credentials/index.d.ts +4 -0
  36. package/dist/src/credentials/index.d.ts.map +1 -0
  37. package/dist/src/credentials/index.js +4 -0
  38. package/dist/src/credentials/index.js.map +1 -0
  39. package/dist/src/credentials/resolver.d.ts +36 -0
  40. package/dist/src/credentials/resolver.d.ts.map +1 -0
  41. package/dist/src/credentials/resolver.js +427 -0
  42. package/dist/src/credentials/resolver.js.map +1 -0
  43. package/dist/src/credentials/types.d.ts +73 -0
  44. package/dist/src/credentials/types.d.ts.map +1 -0
  45. package/dist/src/credentials/types.js +5 -0
  46. package/dist/src/credentials/types.js.map +1 -0
  47. package/dist/src/guardrails/index.d.ts +3 -0
  48. package/dist/src/guardrails/index.d.ts.map +1 -0
  49. package/dist/src/guardrails/index.js +3 -0
  50. package/dist/src/guardrails/index.js.map +1 -0
  51. package/dist/src/guardrails/safety.d.ts +45 -0
  52. package/dist/src/guardrails/safety.d.ts.map +1 -0
  53. package/dist/src/guardrails/safety.js +217 -0
  54. package/dist/src/guardrails/safety.js.map +1 -0
  55. package/dist/src/guardrails/types.d.ts +36 -0
  56. package/dist/src/guardrails/types.d.ts.map +1 -0
  57. package/dist/src/guardrails/types.js +20 -0
  58. package/dist/src/guardrails/types.js.map +1 -0
  59. package/dist/src/hypervisors/esxi/client.d.ts +103 -0
  60. package/dist/src/hypervisors/esxi/client.d.ts.map +1 -0
  61. package/dist/src/hypervisors/esxi/client.js +395 -0
  62. package/dist/src/hypervisors/esxi/client.js.map +1 -0
  63. package/dist/src/hypervisors/esxi/mock.d.ts +45 -0
  64. package/dist/src/hypervisors/esxi/mock.d.ts.map +1 -0
  65. package/dist/src/hypervisors/esxi/mock.js +202 -0
  66. package/dist/src/hypervisors/esxi/mock.js.map +1 -0
  67. package/dist/src/hypervisors/index.d.ts +10 -0
  68. package/dist/src/hypervisors/index.d.ts.map +1 -0
  69. package/dist/src/hypervisors/index.js +10 -0
  70. package/dist/src/hypervisors/index.js.map +1 -0
  71. package/dist/src/hypervisors/manager.d.ts +78 -0
  72. package/dist/src/hypervisors/manager.d.ts.map +1 -0
  73. package/dist/src/hypervisors/manager.js +415 -0
  74. package/dist/src/hypervisors/manager.js.map +1 -0
  75. package/dist/src/hypervisors/proxmox/client.d.ts +29 -0
  76. package/dist/src/hypervisors/proxmox/client.d.ts.map +1 -0
  77. package/dist/src/hypervisors/proxmox/client.js +278 -0
  78. package/dist/src/hypervisors/proxmox/client.js.map +1 -0
  79. package/dist/src/hypervisors/proxmox/mock.d.ts +17 -0
  80. package/dist/src/hypervisors/proxmox/mock.d.ts.map +1 -0
  81. package/dist/src/hypervisors/proxmox/mock.js +26 -0
  82. package/dist/src/hypervisors/proxmox/mock.js.map +1 -0
  83. package/dist/src/hypervisors/types.d.ts +159 -0
  84. package/dist/src/hypervisors/types.d.ts.map +1 -0
  85. package/dist/src/hypervisors/types.js +6 -0
  86. package/dist/src/hypervisors/types.js.map +1 -0
  87. package/dist/src/index.d.ts +19 -0
  88. package/dist/src/index.d.ts.map +1 -0
  89. package/dist/src/index.js +19 -0
  90. package/dist/src/index.js.map +1 -0
  91. package/dist/src/proxmox/client.d.ts +37 -0
  92. package/dist/src/proxmox/client.d.ts.map +1 -0
  93. package/dist/src/proxmox/client.js +282 -0
  94. package/dist/src/proxmox/client.js.map +1 -0
  95. package/dist/src/proxmox/index.d.ts +53 -0
  96. package/dist/src/proxmox/index.d.ts.map +1 -0
  97. package/dist/src/proxmox/index.js +109 -0
  98. package/dist/src/proxmox/index.js.map +1 -0
  99. package/dist/src/proxmox/mock.d.ts +40 -0
  100. package/dist/src/proxmox/mock.d.ts.map +1 -0
  101. package/dist/src/proxmox/mock.js +362 -0
  102. package/dist/src/proxmox/mock.js.map +1 -0
  103. package/dist/src/proxmox/poller.d.ts +29 -0
  104. package/dist/src/proxmox/poller.d.ts.map +1 -0
  105. package/dist/src/proxmox/poller.js +107 -0
  106. package/dist/src/proxmox/poller.js.map +1 -0
  107. package/dist/src/server/index.d.ts +2 -0
  108. package/dist/src/server/index.d.ts.map +1 -0
  109. package/dist/src/server/index.js +2 -0
  110. package/dist/src/server/index.js.map +1 -0
  111. package/dist/src/server/mcp-server.d.ts +5 -0
  112. package/dist/src/server/mcp-server.d.ts.map +1 -0
  113. package/dist/src/server/mcp-server.js +5 -0
  114. package/dist/src/server/mcp-server.js.map +1 -0
  115. package/dist/src/server.d.ts +35 -0
  116. package/dist/src/server.d.ts.map +1 -0
  117. package/dist/src/server.js +95 -0
  118. package/dist/src/server.js.map +1 -0
  119. package/dist/src/slot/gc.d.ts +42 -0
  120. package/dist/src/slot/gc.d.ts.map +1 -0
  121. package/dist/src/slot/gc.js +79 -0
  122. package/dist/src/slot/gc.js.map +1 -0
  123. package/dist/src/slot/index.d.ts +4 -0
  124. package/dist/src/slot/index.d.ts.map +1 -0
  125. package/dist/src/slot/index.js +4 -0
  126. package/dist/src/slot/index.js.map +1 -0
  127. package/dist/src/slot/manager.d.ts +92 -0
  128. package/dist/src/slot/manager.d.ts.map +1 -0
  129. package/dist/src/slot/manager.js +650 -0
  130. package/dist/src/slot/manager.js.map +1 -0
  131. package/dist/src/slot/types.d.ts +184 -0
  132. package/dist/src/slot/types.d.ts.map +1 -0
  133. package/dist/src/slot/types.js +67 -0
  134. package/dist/src/slot/types.js.map +1 -0
  135. package/dist/src/tools/appliance-exec.d.ts +81 -0
  136. package/dist/src/tools/appliance-exec.d.ts.map +1 -0
  137. package/dist/src/tools/appliance-exec.js +134 -0
  138. package/dist/src/tools/appliance-exec.js.map +1 -0
  139. package/dist/src/tools/claim-slot.d.ts +5 -0
  140. package/dist/src/tools/claim-slot.d.ts.map +1 -0
  141. package/dist/src/tools/claim-slot.js +5 -0
  142. package/dist/src/tools/claim-slot.js.map +1 -0
  143. package/dist/src/tools/index.d.ts +26 -0
  144. package/dist/src/tools/index.d.ts.map +1 -0
  145. package/dist/src/tools/index.js +63 -0
  146. package/dist/src/tools/index.js.map +1 -0
  147. package/dist/src/tools/list-hosts.d.ts +41 -0
  148. package/dist/src/tools/list-hosts.d.ts.map +1 -0
  149. package/dist/src/tools/list-hosts.js +91 -0
  150. package/dist/src/tools/list-hosts.js.map +1 -0
  151. package/dist/src/tools/list-inventory.d.ts +50 -0
  152. package/dist/src/tools/list-inventory.d.ts.map +1 -0
  153. package/dist/src/tools/list-inventory.js +112 -0
  154. package/dist/src/tools/list-inventory.js.map +1 -0
  155. package/dist/src/tools/list-slots.d.ts +5 -0
  156. package/dist/src/tools/list-slots.d.ts.map +1 -0
  157. package/dist/src/tools/list-slots.js +5 -0
  158. package/dist/src/tools/list-slots.js.map +1 -0
  159. package/dist/src/tools/provision-tools.d.ts +231 -0
  160. package/dist/src/tools/provision-tools.d.ts.map +1 -0
  161. package/dist/src/tools/provision-tools.js +289 -0
  162. package/dist/src/tools/provision-tools.js.map +1 -0
  163. package/dist/src/tools/release-slot.d.ts +5 -0
  164. package/dist/src/tools/release-slot.d.ts.map +1 -0
  165. package/dist/src/tools/release-slot.js +5 -0
  166. package/dist/src/tools/release-slot.js.map +1 -0
  167. package/dist/src/tools/slot-tools.d.ts +173 -0
  168. package/dist/src/tools/slot-tools.d.ts.map +1 -0
  169. package/dist/src/tools/slot-tools.js +296 -0
  170. package/dist/src/tools/slot-tools.js.map +1 -0
  171. package/dist/src/tools/vm-action.d.ts +189 -0
  172. package/dist/src/tools/vm-action.d.ts.map +1 -0
  173. package/dist/src/tools/vm-action.js +300 -0
  174. package/dist/src/tools/vm-action.js.map +1 -0
  175. package/dist/src/transports/index.d.ts +3 -0
  176. package/dist/src/transports/index.d.ts.map +1 -0
  177. package/dist/src/transports/index.js +3 -0
  178. package/dist/src/transports/index.js.map +1 -0
  179. package/dist/src/transports/sse.d.ts +38 -0
  180. package/dist/src/transports/sse.d.ts.map +1 -0
  181. package/dist/src/transports/sse.js +167 -0
  182. package/dist/src/transports/sse.js.map +1 -0
  183. package/dist/src/transports/stdio.d.ts +15 -0
  184. package/dist/src/transports/stdio.d.ts.map +1 -0
  185. package/dist/src/transports/stdio.js +34 -0
  186. package/dist/src/transports/stdio.js.map +1 -0
  187. package/dist/src/types/config.d.ts +17 -0
  188. package/dist/src/types/config.d.ts.map +1 -0
  189. package/dist/src/types/config.js +5 -0
  190. package/dist/src/types/config.js.map +1 -0
  191. package/dist/src/types/index.d.ts +4 -0
  192. package/dist/src/types/index.d.ts.map +1 -0
  193. package/dist/src/types/index.js +4 -0
  194. package/dist/src/types/index.js.map +1 -0
  195. package/dist/src/types/mcp.d.ts +38 -0
  196. package/dist/src/types/mcp.d.ts.map +1 -0
  197. package/dist/src/types/mcp.js +46 -0
  198. package/dist/src/types/mcp.js.map +1 -0
  199. package/dist/src/types/proxmox.d.ts +181 -0
  200. package/dist/src/types/proxmox.d.ts.map +1 -0
  201. package/dist/src/types/proxmox.js +5 -0
  202. package/dist/src/types/proxmox.js.map +1 -0
  203. package/dist/src/types/slot.d.ts +5 -0
  204. package/dist/src/types/slot.d.ts.map +1 -0
  205. package/dist/src/types/slot.js +5 -0
  206. package/dist/src/types/slot.js.map +1 -0
  207. package/dist/src/utils/formatters.d.ts +71 -0
  208. package/dist/src/utils/formatters.d.ts.map +1 -0
  209. package/dist/src/utils/formatters.js +480 -0
  210. package/dist/src/utils/formatters.js.map +1 -0
  211. package/dist/src/utils/index.d.ts +6 -0
  212. package/dist/src/utils/index.d.ts.map +1 -0
  213. package/dist/src/utils/index.js +6 -0
  214. package/dist/src/utils/index.js.map +1 -0
  215. package/dist/src/utils/logger.d.ts +28 -0
  216. package/dist/src/utils/logger.d.ts.map +1 -0
  217. package/dist/src/utils/logger.js +74 -0
  218. package/dist/src/utils/logger.js.map +1 -0
  219. package/dist/src/utils/mutex.d.ts +32 -0
  220. package/dist/src/utils/mutex.d.ts.map +1 -0
  221. package/dist/src/utils/mutex.js +86 -0
  222. package/dist/src/utils/mutex.js.map +1 -0
  223. package/dist/src/utils/redact.d.ts +6 -0
  224. package/dist/src/utils/redact.d.ts.map +1 -0
  225. package/dist/src/utils/redact.js +6 -0
  226. package/dist/src/utils/redact.js.map +1 -0
  227. package/dist/src/utils/redactor.d.ts +50 -0
  228. package/dist/src/utils/redactor.d.ts.map +1 -0
  229. package/dist/src/utils/redactor.js +170 -0
  230. package/dist/src/utils/redactor.js.map +1 -0
  231. package/package.json +75 -0
@@ -0,0 +1,650 @@
1
+ /**
2
+ * Global Slot Allocation & Capacity Partitioning Engine
3
+ * Manages 8GB appliance slots across Proxmox VE and VMware ESXi hosts with hard partition limits,
4
+ * atomic concurrency locking (AsyncMutex), TTL lease management, and candidate slot ranking.
5
+ */
6
+ import { randomUUID } from "node:crypto";
7
+ import { CapacityExceededError, GlobalCapacityExceededError, SlotNotFoundError, LeaseCeilingExceededError, InvalidSlotOperationError, DatastoreCapacityExceededError } from "./types.js";
8
+ import { AsyncMutex } from "../utils/mutex.js";
9
+ export * from "./types.js";
10
+ const DEFAULT_HOST_CONFIGS = [
11
+ {
12
+ hostId: "proxmox-lab",
13
+ name: "Proxmox Lab (Primary)",
14
+ hypervisorType: "proxmox",
15
+ totalMemoryMb: 32768, // 32 GB
16
+ reservedHeadroomMb: 16384, // 16 GB reserved for CUCM and system
17
+ maxSlots: 2, // Hard limit: 2 slots (16 GB)
18
+ slotSizeMb: 8192, // 8 GB per slot
19
+ freeDiskGb: 280,
20
+ totalDiskGb: 500
21
+ },
22
+ {
23
+ hostId: "proxmox-mini",
24
+ name: "Proxmox Mini (Minisforum)",
25
+ hypervisorType: "proxmox",
26
+ totalMemoryMb: 65536, // 64 GB
27
+ reservedHeadroomMb: 32768, // 32 GB reserved
28
+ maxSlots: 4, // Hard limit: 4 slots (32 GB)
29
+ slotSizeMb: 8192, // 8 GB per slot
30
+ freeDiskGb: 650,
31
+ totalDiskGb: 1000
32
+ }
33
+ ];
34
+ export const DEFAULT_ESXI_HOST_CONFIG = {
35
+ hostId: "esxi-intel-192-168-123-176",
36
+ name: "ESXi Intel Lab (192.168.123.176)",
37
+ hypervisorType: "esxi",
38
+ totalMemoryMb: 131072, // 128 GB
39
+ reservedHeadroomMb: 65536, // 64 GB reserved
40
+ maxSlots: 8, // 8 slots (64 GB)
41
+ slotSizeMb: 8192,
42
+ freeDiskGb: 1200,
43
+ totalDiskGb: 1860,
44
+ datastoreFreeBytes: 1200 * 1024 * 1024 * 1024
45
+ };
46
+ export class SlotManager {
47
+ hosts = new Map();
48
+ slots = new Map();
49
+ mutex = new AsyncMutex();
50
+ defaultDurationMinutes;
51
+ proxmoxManager;
52
+ constructor(options = {}) {
53
+ this.defaultDurationMinutes = options.defaultDurationMinutes ?? 60;
54
+ this.proxmoxManager = options.proxmoxManager;
55
+ const hostConfigs = options.hosts && options.hosts.length > 0
56
+ ? options.hosts
57
+ : [...DEFAULT_HOST_CONFIGS];
58
+ if (options.includeEsxi && !hostConfigs.some((h) => h.hostId === "esxi-intel-192-168-123-176")) {
59
+ hostConfigs.push(DEFAULT_ESXI_HOST_CONFIG);
60
+ }
61
+ for (const host of hostConfigs) {
62
+ this.registerHost(host);
63
+ }
64
+ }
65
+ /**
66
+ * Register a host and initialize its slot definitions.
67
+ */
68
+ registerHost(host) {
69
+ const normalizedId = this.normalizeHostId(host.hostId);
70
+ const normalizedHost = {
71
+ ...host,
72
+ hostId: normalizedId,
73
+ hypervisorType: host.hypervisorType || (normalizedId.startsWith("esxi") ? "esxi" : "proxmox")
74
+ };
75
+ this.hosts.set(normalizedId, normalizedHost);
76
+ for (let index = 1; index <= normalizedHost.maxSlots; index++) {
77
+ const slotId = `slot-${normalizedId}-${index}`;
78
+ if (!this.slots.has(slotId)) {
79
+ this.slots.set(slotId, {
80
+ slotId,
81
+ hostId: normalizedId,
82
+ hostName: normalizedHost.name,
83
+ hypervisorType: normalizedHost.hypervisorType,
84
+ slotIndex: index,
85
+ memoryMb: normalizedHost.slotSizeMb,
86
+ allocatedMemoryMb: normalizedHost.slotSizeMb,
87
+ allocatedMemoryBytes: normalizedHost.slotSizeMb * 1024 * 1024,
88
+ status: "AVAILABLE",
89
+ claimId: null,
90
+ claimedAt: null,
91
+ expiresAt: null,
92
+ durationMinutes: null,
93
+ ttlMinutes: null,
94
+ ttlSeconds: null,
95
+ tag: null,
96
+ requester: null,
97
+ linearIssue: null,
98
+ sessionId: null,
99
+ purpose: null,
100
+ vmId: null,
101
+ vmName: null,
102
+ metadata: {}
103
+ });
104
+ }
105
+ }
106
+ }
107
+ /**
108
+ * Normalizes host ID strings (e.g. mapping aliases like proxmox-minis-lab -> proxmox-mini).
109
+ */
110
+ normalizeHostId(hostId) {
111
+ if (hostId === "proxmox-minis-lab")
112
+ return "proxmox-mini";
113
+ return hostId;
114
+ }
115
+ /**
116
+ * Internal lazy expiration sweep (must be called inside mutex or synchronous method).
117
+ */
118
+ sweepExpiredSlotsInternal(now = Date.now()) {
119
+ let expiredCount = 0;
120
+ for (const slot of this.slots.values()) {
121
+ if ((slot.status === "CLAIMED" || slot.status === "active") && slot.expiresAt) {
122
+ const expiresTime = new Date(slot.expiresAt).getTime();
123
+ if (expiresTime <= now) {
124
+ slot.status = "AVAILABLE";
125
+ slot.claimId = null;
126
+ slot.claimedAt = null;
127
+ slot.expiresAt = null;
128
+ slot.durationMinutes = null;
129
+ slot.ttlMinutes = null;
130
+ slot.ttlSeconds = null;
131
+ slot.tag = null;
132
+ slot.requester = null;
133
+ slot.linearIssue = null;
134
+ slot.sessionId = null;
135
+ slot.purpose = null;
136
+ slot.vmId = null;
137
+ slot.vmName = null;
138
+ slot.metadata = {};
139
+ expiredCount++;
140
+ }
141
+ }
142
+ }
143
+ return expiredCount;
144
+ }
145
+ /**
146
+ * Atomically claims an 8GB appliance slot.
147
+ */
148
+ async claimSlot(request) {
149
+ return this.mutex.runExclusive(async () => {
150
+ // 1. Reclaim any expired leases first
151
+ this.sweepExpiredSlotsInternal();
152
+ const tagInput = request.tag || request.linearIssue || request.requester;
153
+ if (!tagInput || typeof tagInput !== "string" || tagInput.trim() === "") {
154
+ throw new InvalidSlotOperationError("A valid non-empty 'tag', 'linearIssue', or 'requester' is required to claim a slot.");
155
+ }
156
+ const durationMinutes = request.durationMinutes ?? request.ttlMinutes ?? (request.ttlSeconds ? Math.ceil(request.ttlSeconds / 60) : this.defaultDurationMinutes);
157
+ if (durationMinutes < 1 || durationMinutes > 1440) {
158
+ throw new InvalidSlotOperationError(`Claim duration must be between 1 and 1440 minutes (requested: ${durationMinutes}).`);
159
+ }
160
+ const rawRequestedHost = request.host || request.targetHost;
161
+ const requestedHost = rawRequestedHost ? this.normalizeHostId(rawRequestedHost) : undefined;
162
+ const hypervisorTypePref = request.hypervisorType || "any";
163
+ // If requestedHost is ESXi and not yet registered, register default ESXi config
164
+ if (requestedHost === "esxi-intel-192-168-123-176" && !this.hosts.has("esxi-intel-192-168-123-176")) {
165
+ this.registerHost(DEFAULT_ESXI_HOST_CONFIG);
166
+ }
167
+ let targetSlot;
168
+ if (requestedHost && requestedHost !== "auto") {
169
+ // Specific host claim
170
+ const hostConfig = this.hosts.get(requestedHost);
171
+ if (!hostConfig) {
172
+ const validHosts = Array.from(this.hosts.keys()).join(", ");
173
+ throw new InvalidSlotOperationError(`Unknown host '${requestedHost}' for slot allocation. Available: [${validHosts}]`);
174
+ }
175
+ // ESXi Datastore check (>= 10GB free required)
176
+ if (hostConfig.hypervisorType === "esxi") {
177
+ const freeDisk = hostConfig.freeDiskGb ?? 100;
178
+ if (freeDisk < 10) {
179
+ throw new DatastoreCapacityExceededError(requestedHost, 10, freeDisk);
180
+ }
181
+ }
182
+ const hostSlots = Array.from(this.slots.values())
183
+ .filter((s) => s.hostId === requestedHost)
184
+ .sort((a, b) => a.slotIndex - b.slotIndex);
185
+ const availableSlot = hostSlots.find((s) => s.status === "AVAILABLE");
186
+ if (!availableSlot) {
187
+ const allocatedCount = hostSlots.filter((s) => s.status === "CLAIMED" || s.status === "active").length;
188
+ if (requestedHost === "proxmox-lab") {
189
+ throw new CapacityExceededError(`Host 'proxmox-lab' has reached its hard quota limit of 2 concurrent 8GB slots (16GB allocated). 16GB is strictly reserved for CUCM and core lab infrastructure.`, "proxmox-lab", hostConfig.maxSlots, allocatedCount);
190
+ }
191
+ else if (requestedHost === "proxmox-mini") {
192
+ throw new CapacityExceededError(`Host 'proxmox-mini' has reached its hard quota limit of 4 concurrent 8GB slots (32GB allocated).`, "proxmox-mini", hostConfig.maxSlots, allocatedCount);
193
+ }
194
+ else {
195
+ throw new CapacityExceededError(`Host '${requestedHost}' has reached its hard quota limit of ${hostConfig.maxSlots} concurrent slots.`, requestedHost, hostConfig.maxSlots, allocatedCount);
196
+ }
197
+ }
198
+ targetSlot = availableSlot;
199
+ }
200
+ else {
201
+ // Deterministic Auto-Placement
202
+ // If hypervisorType is specified as esxi, auto-register default ESXi if not present
203
+ if (hypervisorTypePref === "esxi" && !this.hosts.has("esxi-intel-192-168-123-176")) {
204
+ this.registerHost(DEFAULT_ESXI_HOST_CONFIG);
205
+ }
206
+ // Filter candidate hosts based on hypervisor preference
207
+ const candidateHostConfigs = Array.from(this.hosts.values()).filter((h) => {
208
+ if (hypervisorTypePref === "proxmox" && h.hypervisorType !== "proxmox")
209
+ return false;
210
+ if (hypervisorTypePref === "esxi" && h.hypervisorType !== "esxi")
211
+ return false;
212
+ return true;
213
+ });
214
+ // Evaluate available slots and datastore health per host
215
+ const hostStats = candidateHostConfigs.map((h) => {
216
+ const hostSlots = Array.from(this.slots.values()).filter((s) => s.hostId === h.hostId);
217
+ const availableSlots = hostSlots.filter((s) => s.status === "AVAILABLE").length;
218
+ const freeDiskGb = h.freeDiskGb ?? 100;
219
+ const datastoreOk = h.hypervisorType !== "esxi" || freeDiskGb >= 10;
220
+ return { host: h, availableSlots, hostSlots, datastoreOk };
221
+ });
222
+ const totalAvailable = hostStats.reduce((sum, h) => sum + (h.datastoreOk ? h.availableSlots : 0), 0);
223
+ if (totalAvailable <= 0) {
224
+ if (hypervisorTypePref === "proxmox") {
225
+ throw new CapacityExceededError("All Proxmox cluster slots are currently allocated (2/2 on proxmox-lab, 4/4 on proxmox-mini).", "proxmox-cluster", 6, 6);
226
+ }
227
+ throw new GlobalCapacityExceededError("All 6 cluster slots are currently allocated (2/2 on proxmox-lab, 4/4 on proxmox-mini).", 6, 6);
228
+ }
229
+ // Sort candidates:
230
+ // 1. Only datastoreOk hosts with availableSlots > 0
231
+ // 2. Descending by availableSlots (least loaded first)
232
+ // 3. Tie-Breaker: prefer proxmox-mini if tied with proxmox-lab
233
+ const validCandidates = hostStats.filter((h) => h.datastoreOk && h.availableSlots > 0);
234
+ validCandidates.sort((a, b) => {
235
+ if (b.availableSlots !== a.availableSlots) {
236
+ return b.availableSlots - a.availableSlots;
237
+ }
238
+ // Tie breaker: prefer proxmox-mini
239
+ if (a.host.hostId === "proxmox-mini" && b.host.hostId !== "proxmox-mini")
240
+ return -1;
241
+ if (b.host.hostId === "proxmox-mini" && a.host.hostId !== "proxmox-mini")
242
+ return 1;
243
+ return 0;
244
+ });
245
+ const winningHostStat = validCandidates[0];
246
+ if (!winningHostStat) {
247
+ throw new GlobalCapacityExceededError("All 6 cluster slots are currently allocated (2/2 on proxmox-lab, 4/4 on proxmox-mini).", 6, 6);
248
+ }
249
+ const availableSlotsOnWinner = winningHostStat.hostSlots
250
+ .filter((s) => s.status === "AVAILABLE")
251
+ .sort((a, b) => a.slotIndex - b.slotIndex);
252
+ targetSlot = availableSlotsOnWinner[0];
253
+ }
254
+ if (!targetSlot) {
255
+ throw new GlobalCapacityExceededError("All 6 cluster slots are currently allocated (2/2 on proxmox-lab, 4/4 on proxmox-mini).", 6, 6);
256
+ }
257
+ // Assign claim details
258
+ const claimId = `claim-${randomUUID().substring(0, 8)}`;
259
+ const now = new Date();
260
+ const claimedAt = now.toISOString();
261
+ const expiresAt = new Date(now.getTime() + durationMinutes * 60 * 1000).toISOString();
262
+ targetSlot.status = "CLAIMED";
263
+ targetSlot.claimId = claimId;
264
+ targetSlot.claimedAt = claimedAt;
265
+ targetSlot.expiresAt = expiresAt;
266
+ targetSlot.durationMinutes = durationMinutes;
267
+ targetSlot.ttlMinutes = durationMinutes;
268
+ targetSlot.ttlSeconds = durationMinutes * 60;
269
+ targetSlot.tag = (request.tag || tagInput).trim();
270
+ targetSlot.requester = request.requester || request.tag || null;
271
+ targetSlot.linearIssue = request.linearIssue || (request.tag && request.tag.startsWith("CTUAT-") ? request.tag : null);
272
+ targetSlot.sessionId = request.sessionId || null;
273
+ targetSlot.purpose = request.purpose || null;
274
+ targetSlot.metadata = request.metadata || {};
275
+ return {
276
+ claimId,
277
+ slotId: targetSlot.slotId,
278
+ hostId: targetSlot.hostId,
279
+ hostName: targetSlot.hostName || targetSlot.hostId,
280
+ hypervisorType: targetSlot.hypervisorType || (targetSlot.hostId.startsWith("esxi") ? "esxi" : "proxmox"),
281
+ slotIndex: targetSlot.slotIndex,
282
+ memoryMb: targetSlot.memoryMb,
283
+ allocatedMemoryMb: targetSlot.memoryMb,
284
+ allocatedMemoryBytes: targetSlot.memoryMb * 1024 * 1024,
285
+ tag: targetSlot.tag,
286
+ requester: targetSlot.requester || undefined,
287
+ linearIssue: targetSlot.linearIssue || undefined,
288
+ claimedAt,
289
+ expiresAt,
290
+ durationMinutes,
291
+ ttlMinutes: durationMinutes,
292
+ ttlSeconds: durationMinutes * 60,
293
+ status: "active",
294
+ purpose: targetSlot.purpose || undefined,
295
+ sessionId: targetSlot.sessionId || undefined
296
+ };
297
+ });
298
+ }
299
+ /**
300
+ * Releases one or more slots matching claimId, tag, slotId, requester, or linearIssue.
301
+ */
302
+ async releaseSlot(optionsOrClaimId, destroyVmFlag) {
303
+ return this.mutex.runExclusive(async () => {
304
+ this.sweepExpiredSlotsInternal();
305
+ let claimId;
306
+ let tag;
307
+ let slotId;
308
+ let sessionId;
309
+ let requester;
310
+ let linearIssue;
311
+ let destroyVm = destroyVmFlag;
312
+ if (typeof optionsOrClaimId === "string") {
313
+ const str = optionsOrClaimId.trim();
314
+ if (str.startsWith("claim-") || str.includes("-")) {
315
+ // Check if claimId exists first
316
+ const existsAsClaim = Array.from(this.slots.values()).some((s) => s.claimId === str);
317
+ if (existsAsClaim) {
318
+ claimId = str;
319
+ }
320
+ else if (str.startsWith("CTUAT-")) {
321
+ linearIssue = str;
322
+ }
323
+ else {
324
+ claimId = str;
325
+ }
326
+ }
327
+ else {
328
+ tag = str;
329
+ }
330
+ }
331
+ else if (optionsOrClaimId && typeof optionsOrClaimId === "object") {
332
+ claimId = optionsOrClaimId.claimId;
333
+ tag = optionsOrClaimId.tag;
334
+ slotId = optionsOrClaimId.slotId;
335
+ sessionId = optionsOrClaimId.sessionId;
336
+ requester = optionsOrClaimId.requester;
337
+ linearIssue = optionsOrClaimId.linearIssue;
338
+ if (optionsOrClaimId.destroyVm !== undefined) {
339
+ destroyVm = optionsOrClaimId.destroyVm;
340
+ }
341
+ }
342
+ if (!claimId && !tag && !slotId && !requester && !linearIssue) {
343
+ throw new InvalidSlotOperationError("Must specify claimId, tag, slotId, requester, or linearIssue to release a slot.");
344
+ }
345
+ const matchingSlots = [];
346
+ for (const slot of this.slots.values()) {
347
+ if (slot.status !== "CLAIMED" && slot.status !== "active")
348
+ continue;
349
+ let match = false;
350
+ if (claimId && slot.claimId === claimId)
351
+ match = true;
352
+ if (slotId && slot.slotId === slotId)
353
+ match = true;
354
+ if (tag && slot.tag === tag)
355
+ match = true;
356
+ if (requester && (slot.requester === requester || slot.tag === requester))
357
+ match = true;
358
+ if (linearIssue && (slot.linearIssue === linearIssue || slot.tag === linearIssue))
359
+ match = true;
360
+ if (match) {
361
+ if (sessionId && slot.sessionId && slot.sessionId !== sessionId) {
362
+ throw new InvalidSlotOperationError(`Unauthorized: session '${sessionId}' does not own slot '${slot.slotId}' (owned by '${slot.sessionId}').`);
363
+ }
364
+ matchingSlots.push(slot);
365
+ }
366
+ }
367
+ if (matchingSlots.length === 0 && claimId) {
368
+ throw new SlotNotFoundError(`No active claimed slot found matching claimId '${claimId}'.`);
369
+ }
370
+ const releasedSnapshots = [];
371
+ const releasedClaims = [];
372
+ const destroyedVms = [];
373
+ let freedMemoryMb = 0;
374
+ for (const slot of matchingSlots) {
375
+ releasedSnapshots.push({ ...slot, status: "RELEASED" });
376
+ if (slot.claimId) {
377
+ releasedClaims.push(slot.claimId);
378
+ }
379
+ freedMemoryMb += slot.memoryMb;
380
+ if (destroyVm && slot.vmId !== null && slot.vmId !== undefined) {
381
+ destroyedVms.push(slot.vmId);
382
+ }
383
+ slot.status = "AVAILABLE";
384
+ slot.claimId = null;
385
+ slot.claimedAt = null;
386
+ slot.expiresAt = null;
387
+ slot.durationMinutes = null;
388
+ slot.ttlMinutes = null;
389
+ slot.ttlSeconds = null;
390
+ slot.tag = null;
391
+ slot.requester = null;
392
+ slot.linearIssue = null;
393
+ slot.sessionId = null;
394
+ slot.purpose = null;
395
+ slot.vmId = null;
396
+ slot.vmName = null;
397
+ slot.metadata = {};
398
+ }
399
+ return {
400
+ releasedCount: releasedSnapshots.length,
401
+ releasedSlots: releasedSnapshots,
402
+ releasedClaims,
403
+ destroyedVms,
404
+ freedMemoryMb,
405
+ message: `Successfully released ${releasedSnapshots.length} slot(s).`
406
+ };
407
+ });
408
+ }
409
+ /**
410
+ * Extends the lease of an active slot up to the 24h cumulative ceiling.
411
+ */
412
+ async extendLease(claimId, additionalMinutes, sessionId) {
413
+ return this.mutex.runExclusive(async () => {
414
+ this.sweepExpiredSlotsInternal();
415
+ if (!claimId) {
416
+ throw new InvalidSlotOperationError("A valid 'claimId' is required to extend a lease.");
417
+ }
418
+ if (!additionalMinutes || additionalMinutes <= 0) {
419
+ throw new InvalidSlotOperationError(`additionalMinutes must be greater than 0 (requested: ${additionalMinutes}).`);
420
+ }
421
+ const slot = Array.from(this.slots.values()).find((s) => s.claimId === claimId && (s.status === "CLAIMED" || s.status === "active"));
422
+ if (!slot || !slot.claimedAt || !slot.expiresAt) {
423
+ throw new SlotNotFoundError(`Active slot with claimId '${claimId}' not found.`);
424
+ }
425
+ if (sessionId && slot.sessionId && slot.sessionId !== sessionId) {
426
+ throw new InvalidSlotOperationError(`Unauthorized: session '${sessionId}' does not match slot owner '${slot.sessionId}'.`);
427
+ }
428
+ const initialTime = new Date(slot.claimedAt).getTime();
429
+ const currentExpiresTime = new Date(slot.expiresAt).getTime();
430
+ const newExpiresTime = currentExpiresTime + additionalMinutes * 60 * 1000;
431
+ const totalDurationMinutes = Math.round((newExpiresTime - initialTime) / (60 * 1000));
432
+ if (totalDurationMinutes > 1440) {
433
+ throw new LeaseCeilingExceededError(`Lease extension of ${additionalMinutes}m would exceed maximum 24h (1440m) ceiling. Total requested: ${totalDurationMinutes}m.`, 1440);
434
+ }
435
+ const previousExpiresAt = slot.expiresAt;
436
+ const newExpiresAt = new Date(newExpiresTime).toISOString();
437
+ slot.expiresAt = newExpiresAt;
438
+ slot.durationMinutes = totalDurationMinutes;
439
+ slot.ttlMinutes = totalDurationMinutes;
440
+ slot.ttlSeconds = totalDurationMinutes * 60;
441
+ return {
442
+ claimId,
443
+ slotId: slot.slotId,
444
+ hostId: slot.hostId,
445
+ slotIndex: slot.slotIndex,
446
+ previousExpiresAt,
447
+ newExpiresAt,
448
+ extendedByMinutes: additionalMinutes,
449
+ totalDurationMinutes
450
+ };
451
+ });
452
+ }
453
+ /**
454
+ * List slots with optional filtering and computed remaining TTL.
455
+ */
456
+ async listSlots(options = {}) {
457
+ return this.mutex.runExclusive(async () => {
458
+ this.sweepExpiredSlotsInternal();
459
+ const now = Date.now();
460
+ const results = [];
461
+ const filterHost = options.host || options.targetHost;
462
+ const normalizedFilterHost = filterHost ? this.normalizeHostId(filterHost) : undefined;
463
+ for (const slot of this.slots.values()) {
464
+ if (normalizedFilterHost && slot.hostId !== normalizedFilterHost)
465
+ continue;
466
+ if (options.tag && slot.tag !== options.tag)
467
+ continue;
468
+ if (options.requester && slot.requester !== options.requester && slot.tag !== options.requester)
469
+ continue;
470
+ if (options.linearIssue && slot.linearIssue !== options.linearIssue && slot.tag !== options.linearIssue)
471
+ continue;
472
+ if (options.activeOnly && slot.status !== "CLAIMED" && slot.status !== "active")
473
+ continue;
474
+ if (options.hypervisorType &&
475
+ options.hypervisorType !== "any" &&
476
+ options.hypervisorType !== "all" &&
477
+ slot.hypervisorType !== options.hypervisorType) {
478
+ continue;
479
+ }
480
+ let remainingTtlSeconds = null;
481
+ if ((slot.status === "CLAIMED" || slot.status === "active") && slot.expiresAt) {
482
+ const diffMs = new Date(slot.expiresAt).getTime() - now;
483
+ remainingTtlSeconds = Math.max(0, Math.floor(diffMs / 1000));
484
+ }
485
+ results.push({
486
+ ...slot,
487
+ remainingTtlSeconds
488
+ });
489
+ }
490
+ // Sort by hostId then slotIndex
491
+ return results.sort((a, b) => {
492
+ if (a.hostId !== b.hostId)
493
+ return a.hostId.localeCompare(b.hostId);
494
+ return a.slotIndex - b.slotIndex;
495
+ });
496
+ });
497
+ }
498
+ /**
499
+ * List host capacities or global cluster summary.
500
+ */
501
+ async listHosts(hostId) {
502
+ return this.mutex.runExclusive(async () => {
503
+ this.sweepExpiredSlotsInternal();
504
+ const normalizedHostId = hostId && hostId !== "all" ? this.normalizeHostId(hostId) : hostId;
505
+ // If specific ESXi host requested and not registered, auto-register
506
+ if (normalizedHostId === "esxi-intel-192-168-123-176" && !this.hosts.has("esxi-intel-192-168-123-176")) {
507
+ this.registerHost(DEFAULT_ESXI_HOST_CONFIG);
508
+ }
509
+ const now = Date.now();
510
+ const hostsSummary = {};
511
+ let clusterMaxSlots = 0;
512
+ let clusterAllocatedSlots = 0;
513
+ let clusterAvailableSlots = 0;
514
+ for (const [id, config] of this.hosts.entries()) {
515
+ const hostSlots = Array.from(this.slots.values())
516
+ .filter((s) => s.hostId === id)
517
+ .map((s) => {
518
+ let remainingTtlSeconds = null;
519
+ if ((s.status === "CLAIMED" || s.status === "active") && s.expiresAt) {
520
+ const diffMs = new Date(s.expiresAt).getTime() - now;
521
+ remainingTtlSeconds = Math.max(0, Math.floor(diffMs / 1000));
522
+ }
523
+ return { ...s, remainingTtlSeconds };
524
+ })
525
+ .sort((a, b) => a.slotIndex - b.slotIndex);
526
+ const activeSlots = hostSlots.filter((s) => s.status === "CLAIMED" || s.status === "active");
527
+ const allocatedSlots = activeSlots.length;
528
+ const availableSlots = config.maxSlots - allocatedSlots;
529
+ clusterMaxSlots += config.maxSlots;
530
+ clusterAllocatedSlots += allocatedSlots;
531
+ clusterAvailableSlots += availableSlots;
532
+ hostsSummary[id] = {
533
+ hostId: id,
534
+ name: config.name,
535
+ hypervisorType: config.hypervisorType,
536
+ totalMemoryMb: config.totalMemoryMb,
537
+ reservedHeadroomMb: config.reservedHeadroomMb,
538
+ maxSlots: config.maxSlots,
539
+ allocatedSlots,
540
+ availableSlots,
541
+ slotSizeMb: config.slotSizeMb,
542
+ freeDiskGb: config.freeDiskGb,
543
+ totalDiskGb: config.totalDiskGb,
544
+ datastoreFreeBytes: config.datastoreFreeBytes,
545
+ activeSlots
546
+ };
547
+ }
548
+ if (normalizedHostId && normalizedHostId !== "all") {
549
+ const single = hostsSummary[normalizedHostId];
550
+ if (!single) {
551
+ throw new InvalidSlotOperationError(`Host '${normalizedHostId}' not found.`);
552
+ }
553
+ return single;
554
+ }
555
+ return {
556
+ clusterMaxSlots,
557
+ clusterAllocatedSlots,
558
+ clusterAvailableSlots,
559
+ hosts: hostsSummary
560
+ };
561
+ });
562
+ }
563
+ /**
564
+ * Returns available slot capacity report conforming to ISlotManager.getAvailableSlots.
565
+ */
566
+ async getAvailableSlots(hostName) {
567
+ const summary = await this.listHosts(hostName);
568
+ if ("allocatedSlots" in summary) {
569
+ return summary;
570
+ }
571
+ return {
572
+ maxSlots: summary.clusterMaxSlots,
573
+ usedSlots: summary.clusterAllocatedSlots,
574
+ allocatedSlots: summary.clusterAllocatedSlots,
575
+ availableSlots: summary.clusterAvailableSlots,
576
+ slotSizeBytes: 8192 * 1024 * 1024,
577
+ slotSizeMb: 8192,
578
+ hosts: summary.hosts
579
+ };
580
+ }
581
+ /**
582
+ * Reclaim all expired slots and return count.
583
+ */
584
+ async reclaimExpired() {
585
+ return this.mutex.runExclusive(async () => {
586
+ return this.sweepExpiredSlotsInternal();
587
+ });
588
+ }
589
+ /**
590
+ * Attach a created VMID or VM name to a claimed slot.
591
+ */
592
+ async attachVm(claimId, vmId, vmName) {
593
+ return this.mutex.runExclusive(async () => {
594
+ this.sweepExpiredSlotsInternal();
595
+ const slot = Array.from(this.slots.values()).find((s) => s.claimId === claimId && (s.status === "CLAIMED" || s.status === "active"));
596
+ if (!slot) {
597
+ throw new SlotNotFoundError(`Active slot with claimId '${claimId}' not found.`);
598
+ }
599
+ slot.vmId = vmId;
600
+ if (vmName)
601
+ slot.vmName = vmName;
602
+ return { ...slot };
603
+ });
604
+ }
605
+ /**
606
+ * Lookup single slot by slotId.
607
+ */
608
+ async getSlot(slotId) {
609
+ return this.mutex.runExclusive(async () => {
610
+ this.sweepExpiredSlotsInternal();
611
+ const slot = this.slots.get(slotId);
612
+ return slot ? { ...slot } : null;
613
+ });
614
+ }
615
+ /**
616
+ * Lookup slot by active claimId.
617
+ */
618
+ async getSlotByClaimId(claimId) {
619
+ return this.mutex.runExclusive(async () => {
620
+ this.sweepExpiredSlotsInternal();
621
+ const slot = Array.from(this.slots.values()).find((s) => s.claimId === claimId && (s.status === "CLAIMED" || s.status === "active"));
622
+ return slot ? { ...slot } : null;
623
+ });
624
+ }
625
+ /**
626
+ * Clears all claims (primarily for test resets).
627
+ */
628
+ async clearAll() {
629
+ await this.mutex.runExclusive(async () => {
630
+ for (const slot of this.slots.values()) {
631
+ slot.status = "AVAILABLE";
632
+ slot.claimId = null;
633
+ slot.claimedAt = null;
634
+ slot.expiresAt = null;
635
+ slot.durationMinutes = null;
636
+ slot.ttlMinutes = null;
637
+ slot.ttlSeconds = null;
638
+ slot.tag = null;
639
+ slot.requester = null;
640
+ slot.linearIssue = null;
641
+ slot.sessionId = null;
642
+ slot.purpose = null;
643
+ slot.vmId = null;
644
+ slot.vmName = null;
645
+ slot.metadata = {};
646
+ }
647
+ });
648
+ }
649
+ }
650
+ //# sourceMappingURL=manager.js.map