@calltelemetry/ct-lab-mcp 0.8.65 → 0.8.66

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 (80) hide show
  1. package/README.md +8 -7
  2. package/dist/src/crd/reconciler.d.ts.map +1 -1
  3. package/dist/src/crd/reconciler.js +4 -1
  4. package/dist/src/crd/reconciler.js.map +1 -1
  5. package/dist/src/credentials/resolver.d.ts.map +1 -1
  6. package/dist/src/credentials/resolver.js +14 -16
  7. package/dist/src/credentials/resolver.js.map +1 -1
  8. package/dist/src/credentials/types.d.ts +2 -1
  9. package/dist/src/credentials/types.d.ts.map +1 -1
  10. package/dist/src/hypervisors/clone-size.d.ts +5 -0
  11. package/dist/src/hypervisors/clone-size.d.ts.map +1 -0
  12. package/dist/src/hypervisors/clone-size.js +27 -0
  13. package/dist/src/hypervisors/clone-size.js.map +1 -0
  14. package/dist/src/hypervisors/esxi/client.d.ts +1 -0
  15. package/dist/src/hypervisors/esxi/client.d.ts.map +1 -1
  16. package/dist/src/hypervisors/esxi/client.js +33 -6
  17. package/dist/src/hypervisors/esxi/client.js.map +1 -1
  18. package/dist/src/hypervisors/esxi/mock.d.ts.map +1 -1
  19. package/dist/src/hypervisors/esxi/mock.js +2 -0
  20. package/dist/src/hypervisors/esxi/mock.js.map +1 -1
  21. package/dist/src/hypervisors/manager.d.ts +6 -1
  22. package/dist/src/hypervisors/manager.d.ts.map +1 -1
  23. package/dist/src/hypervisors/manager.js +30 -9
  24. package/dist/src/hypervisors/manager.js.map +1 -1
  25. package/dist/src/hypervisors/proxmox/client.d.ts.map +1 -1
  26. package/dist/src/hypervisors/proxmox/client.js +29 -28
  27. package/dist/src/hypervisors/proxmox/client.js.map +1 -1
  28. package/dist/src/hypervisors/types.d.ts +3 -0
  29. package/dist/src/hypervisors/types.d.ts.map +1 -1
  30. package/dist/src/proxmox/mock.d.ts.map +1 -1
  31. package/dist/src/proxmox/mock.js +8 -1
  32. package/dist/src/proxmox/mock.js.map +1 -1
  33. package/dist/src/server.js +3 -3
  34. package/dist/src/server.js.map +1 -1
  35. package/dist/src/slot/manager.d.ts +22 -3
  36. package/dist/src/slot/manager.d.ts.map +1 -1
  37. package/dist/src/slot/manager.js +275 -258
  38. package/dist/src/slot/manager.js.map +1 -1
  39. package/dist/src/slot/types.d.ts +17 -0
  40. package/dist/src/slot/types.d.ts.map +1 -1
  41. package/dist/src/slot/types.js +1 -1
  42. package/dist/src/slot/types.js.map +1 -1
  43. package/dist/src/tools/claimed-import-qcow.d.ts +2 -2
  44. package/dist/src/tools/cube-provision.d.ts.map +1 -1
  45. package/dist/src/tools/cube-provision.js +47 -47
  46. package/dist/src/tools/cube-provision.js.map +1 -1
  47. package/dist/src/tools/find-slot.d.ts +12 -0
  48. package/dist/src/tools/find-slot.d.ts.map +1 -1
  49. package/dist/src/tools/find-slot.js +6 -0
  50. package/dist/src/tools/find-slot.js.map +1 -1
  51. package/dist/src/tools/host-migrate-vm.d.ts +1 -1
  52. package/dist/src/tools/host-migrate-vm.js +1 -1
  53. package/dist/src/tools/import-qcow.d.ts +6 -6
  54. package/dist/src/tools/index.js +2 -2
  55. package/dist/src/tools/index.js.map +1 -1
  56. package/dist/src/tools/legacy-clone-plan.d.ts +27 -0
  57. package/dist/src/tools/legacy-clone-plan.d.ts.map +1 -0
  58. package/dist/src/tools/legacy-clone-plan.js +62 -0
  59. package/dist/src/tools/legacy-clone-plan.js.map +1 -0
  60. package/dist/src/tools/list-hosts.d.ts +1 -1
  61. package/dist/src/tools/list-hosts.js +1 -1
  62. package/dist/src/tools/provision-tools.d.ts +8 -8
  63. package/dist/src/tools/provision-tools.d.ts.map +1 -1
  64. package/dist/src/tools/provision-tools.js +47 -82
  65. package/dist/src/tools/provision-tools.js.map +1 -1
  66. package/dist/src/tools/quick-provision.d.ts +2 -2
  67. package/dist/src/tools/sandbox-tools.d.ts +6 -6
  68. package/dist/src/tools/sandbox-tools.d.ts.map +1 -1
  69. package/dist/src/tools/sandbox-tools.js +3 -3
  70. package/dist/src/tools/sandbox-tools.js.map +1 -1
  71. package/dist/src/tools/slot-tools.d.ts +20 -8
  72. package/dist/src/tools/slot-tools.d.ts.map +1 -1
  73. package/dist/src/tools/slot-tools.js +4 -0
  74. package/dist/src/tools/slot-tools.js.map +1 -1
  75. package/dist/src/types/proxmox.d.ts +1 -0
  76. package/dist/src/types/proxmox.d.ts.map +1 -1
  77. package/dist/src/utils/formatters.d.ts.map +1 -1
  78. package/dist/src/utils/formatters.js +19 -20
  79. package/dist/src/utils/formatters.js.map +1 -1
  80. package/package.json +1 -1
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Global Slot Allocation & Capacity Partitioning Engine
3
- * Manages 8GB appliance slots across Proxmox VE and VMware ESXi hosts with hard partition limits,
2
+ * Resource-based Slot Allocation Engine
3
+ * Manages identity-bound appliance leases using fresh physical resource admission,
4
4
  * atomic concurrency locking (AsyncMutex), TTL lease management, candidate slot ranking,
5
5
  * structured metadata labeling/indexing, and automated expired slot purge & reclamation.
6
6
  */
7
7
  import { createHash, randomInt, randomUUID } from "node:crypto";
8
8
  import { CapacityExceededError, GlobalCapacityExceededError, SlotNotFoundError, LeaseCeilingExceededError, InvalidSlotOperationError, DatastoreCapacityExceededError, HypervisorSlotUnsupportedError, CubeNotFoundError, CubeAlreadyClaimedError } from "./types.js";
9
+ import { ProxmoxDriver } from "../hypervisors/proxmox/client.js";
9
10
  import { AsyncMutex } from "../utils/mutex.js";
10
11
  import { defaultSafetyGuardrailEngine } from "../guardrails/safety.js";
11
12
  import { telemetry } from "../telemetry/index.js";
@@ -16,17 +17,10 @@ export * from "./types.js";
16
17
  * Capacity is derived directly from unreserved physical RAM: (total - reserved) / slotSize.
17
18
  */
18
19
  export function calculatePureCapacity(host) {
19
- if (host.maxSlots !== undefined) {
20
- if (host.hypervisorType === "esxi" && host.maxSlots === 0 && host.allowEsxiSlots) {
21
- // Allow dynamic calculation below for ESXi when unlocked
22
- }
23
- else {
24
- return host.maxSlots;
25
- }
26
- }
27
- const slotSize = host.slotSizeMb || 8192;
28
- if (host.totalMemoryMb !== undefined) {
29
- const reserved = host.reservedHeadroomMb || 0;
20
+ const slotSize = host.slotSizeMb ?? 8192;
21
+ const reserved = host.reservedHeadroomMb ?? 0;
22
+ if (Number.isFinite(host.totalMemoryMb) && Number.isFinite(slotSize) && slotSize > 0
23
+ && Number.isFinite(reserved) && reserved >= 0) {
30
24
  const usableMb = Math.max(0, host.totalMemoryMb - reserved);
31
25
  return Math.floor(usableMb / slotSize);
32
26
  }
@@ -37,36 +31,26 @@ const DEFAULT_HOST_CONFIGS = [
37
31
  hostId: "proxmox-lab",
38
32
  name: "Proxmox Lab (Primary)",
39
33
  hypervisorType: "proxmox",
40
- totalMemoryMb: 32768, // 32 GB
41
- reservedHeadroomMb: 16384, // 16 GB reserved for CUCM and system
42
- maxSlots: 2, // Pure capacity: 2 slots (16 GB usable)
34
+ totalMemoryMb: 0, // Unknown until a fresh hypervisor read
35
+ reservedHeadroomMb: 0,
43
36
  slotSizeMb: 8192, // 8 GB per slot
44
- freeDiskGb: 280,
45
- totalDiskGb: 500
46
37
  },
47
38
  {
48
39
  hostId: "proxmox-mini",
49
40
  name: "Proxmox Mini (Minisforum)",
50
41
  hypervisorType: "proxmox",
51
- totalMemoryMb: 65536, // 64 GB
52
- reservedHeadroomMb: 32768, // 32 GB reserved
53
- maxSlots: 4, // Pure capacity: 4 slots (32 GB usable)
42
+ totalMemoryMb: 0,
43
+ reservedHeadroomMb: 0,
54
44
  slotSizeMb: 8192, // 8 GB per slot
55
- freeDiskGb: 650,
56
- totalDiskGb: 1000
57
45
  }
58
46
  ];
59
47
  export const DEFAULT_ESXI_HOST_CONFIG = {
60
48
  hostId: "esxi-intel-192-168-123-176",
61
49
  name: "ESXi Intel Lab (192.168.123.176)",
62
50
  hypervisorType: "esxi",
63
- totalMemoryMb: 131072, // 128 GB
64
- reservedHeadroomMb: 65536, // 64 GB reserved
65
- maxSlots: 0, // Hard limit: 0 slots (slot leasing disabled on ESXi)
51
+ totalMemoryMb: 0,
52
+ reservedHeadroomMb: 0,
66
53
  slotSizeMb: 8192,
67
- freeDiskGb: 1200,
68
- totalDiskGb: 1860,
69
- datastoreFreeBytes: 1200 * 1024 * 1024 * 1024
70
54
  };
71
55
  export const DEFAULT_CUBES = [
72
56
  {
@@ -119,6 +103,7 @@ export class SlotManager {
119
103
  guardrailEngine;
120
104
  telemetry;
121
105
  allowEsxiSlots;
106
+ capacityReader;
122
107
  constructor(options = {}) {
123
108
  this.defaultDurationMinutes = options.defaultDurationMinutes ?? 120;
124
109
  this.proxmoxManager = options.proxmoxManager;
@@ -126,6 +111,7 @@ export class SlotManager {
126
111
  this.guardrailEngine = options.guardrailEngine || defaultSafetyGuardrailEngine;
127
112
  this.telemetry = options.telemetryRegistry || telemetry;
128
113
  this.allowEsxiSlots = options.allowEsxiSlots ?? (process.env.ALLOW_ESXI_SLOTS === "true");
114
+ this.capacityReader = options.capacityReader;
129
115
  const hostConfigs = options.hosts && options.hosts.length > 0
130
116
  ? options.hosts
131
117
  : [...DEFAULT_HOST_CONFIGS];
@@ -152,60 +138,136 @@ export class SlotManager {
152
138
  this.guardrailEngine = guardrailEngine;
153
139
  }
154
140
  /**
155
- * Register a host and initialize its slot definitions.
141
+ * Register host identity. Leases are created on demand after resource admission.
156
142
  */
157
143
  registerHost(host) {
158
144
  const normalizedId = this.normalizeHostId(host.hostId);
159
- let pureSlots = calculatePureCapacity({
160
- ...host,
161
- allowEsxiSlots: this.allowEsxiSlots
162
- });
163
- if (normalizedId.startsWith("esxi") && this.allowEsxiSlots && host.maxSlots === 0) {
164
- const usableMb = Math.max(0, host.totalMemoryMb - (host.reservedHeadroomMb || 0));
165
- pureSlots = Math.floor(usableMb / (host.slotSizeMb || 8192));
166
- }
167
145
  const normalizedHost = {
168
146
  ...host,
169
147
  hostId: normalizedId,
170
- maxSlots: pureSlots,
171
148
  hypervisorType: host.hypervisorType || (normalizedId.startsWith("esxi") ? "esxi" : "proxmox")
172
149
  };
173
150
  this.hosts.set(normalizedId, normalizedHost);
174
- for (let index = 1; index <= pureSlots; index++) {
175
- const slotId = `slot-${normalizedId}-${index}`;
176
- if (!this.slots.has(slotId)) {
177
- this.slots.set(slotId, {
178
- slotId,
179
- hostId: normalizedId,
180
- hostName: normalizedHost.name,
181
- hypervisorType: normalizedHost.hypervisorType,
182
- slotIndex: index,
183
- memoryMb: normalizedHost.slotSizeMb,
184
- allocatedMemoryMb: normalizedHost.slotSizeMb,
185
- allocatedMemoryBytes: normalizedHost.slotSizeMb * 1024 * 1024,
186
- status: "AVAILABLE",
187
- claimId: null,
188
- claimedAt: null,
189
- expiresAt: null,
190
- durationMinutes: null,
191
- ttlMinutes: null,
192
- ttlSeconds: null,
193
- tag: null,
194
- label: null,
195
- workloadType: null,
196
- targetVersion: null,
197
- branch: null,
198
- customTags: [],
199
- requester: null,
200
- linearIssue: null,
201
- sessionId: null,
202
- purpose: null,
203
- vmId: null,
204
- vmName: null,
205
- metadata: {}
206
- });
151
+ }
152
+ createAvailableSlot(host, reuse = true) {
153
+ const hostSlots = Array.from(this.slots.values()).filter((slot) => slot.hostId === host.hostId);
154
+ const existing = hostSlots.find((slot) => slot.status === "AVAILABLE");
155
+ if (reuse && existing)
156
+ return existing;
157
+ const index = Math.max(0, ...hostSlots.map((slot) => slot.slotIndex)) + 1;
158
+ const slotId = `slot-${host.hostId}-${index}`;
159
+ const slot = {
160
+ slotId,
161
+ hostId: host.hostId,
162
+ hostName: host.name,
163
+ hypervisorType: host.hypervisorType,
164
+ slotIndex: index,
165
+ memoryMb: host.slotSizeMb,
166
+ allocatedMemoryMb: host.slotSizeMb,
167
+ allocatedMemoryBytes: host.slotSizeMb * 1024 * 1024,
168
+ status: "AVAILABLE",
169
+ claimId: null,
170
+ claimedAt: null,
171
+ expiresAt: null,
172
+ durationMinutes: null,
173
+ ttlMinutes: null,
174
+ ttlSeconds: null,
175
+ tag: null,
176
+ label: null,
177
+ workloadType: null,
178
+ targetVersion: null,
179
+ branch: null,
180
+ customTags: [],
181
+ requester: null,
182
+ linearIssue: null,
183
+ sessionId: null,
184
+ purpose: null,
185
+ vmId: null,
186
+ vmName: null,
187
+ metadata: {}
188
+ };
189
+ this.slots.set(slotId, slot);
190
+ return slot;
191
+ }
192
+ /** Fresh reads are the only admission authority; registered sizes are diagnostics. */
193
+ async readHostResources(host, storage, omitClaimId) {
194
+ const observedAt = new Date().toISOString();
195
+ const unknown = { status: "unknown", freeMemoryMb: 0, freeDiskGb: 0,
196
+ totalMemoryMb: 0, totalDiskGb: 0, cpuTotalCores: 0, observedAt };
197
+ try {
198
+ let driver;
199
+ try {
200
+ driver = this.hypervisorManager?.getDriver(host.hostId)
201
+ ?? (this.proxmoxManager && host.hypervisorType === "proxmox"
202
+ ? new ProxmoxDriver(this.proxmoxManager.getHostConfig(host.hostId), this.proxmoxManager.getClient(host.hostId)) : undefined);
203
+ }
204
+ catch (error) {
205
+ // A trusted injected reader can supply offline telemetry without a
206
+ // driver. Production has no reader/config fallback and still refuses.
207
+ if (!this.capacityReader)
208
+ throw error;
209
+ }
210
+ const reading = this.capacityReader
211
+ ? await this.capacityReader(host.hostId)
212
+ : await driver?.getHostCapacity();
213
+ const capacity = reading ? { ...reading } : undefined;
214
+ if (!capacity || this.normalizeHostId(capacity.name) !== host.hostId
215
+ || capacity.type !== host.hypervisorType)
216
+ return unknown;
217
+ if (storage) {
218
+ const pool = (await driver?.listStorage?.())?.find((pool) => pool.storage === storage
219
+ && pool.active && pool.enabled && pool.content.split(",").includes("images"));
220
+ if (!pool)
221
+ return unknown;
222
+ capacity.storageTotalBytes = pool.totalBytes;
223
+ capacity.storageFreeBytes = pool.availableBytes;
224
+ }
225
+ const numbers = [capacity.memoryTotalBytes, capacity.memoryFreeBytes,
226
+ capacity.storageTotalBytes, capacity.storageFreeBytes, capacity.cpuTotalCores];
227
+ if (numbers.some((value) => !Number.isFinite(value) || value < 0)
228
+ || capacity.memoryTotalBytes <= 0 || capacity.storageTotalBytes <= 0 || capacity.cpuTotalCores <= 0
229
+ || capacity.memoryFreeBytes > capacity.memoryTotalBytes
230
+ || capacity.storageFreeBytes > capacity.storageTotalBytes)
231
+ return unknown;
232
+ if (capacity.status !== "online")
233
+ return { ...unknown, status: capacity.status };
234
+ // Free RAM/disk already includes running, attached VMs. Pending or stopped
235
+ // claims reserve their requested size under the same claim mutex, so a
236
+ // concurrent read cannot promise the same physical resources twice.
237
+ const occupied = Array.from(this.slots.values()).filter((slot) => slot.hostId === host.hostId && slot.status !== "AVAILABLE" && slot.claimId !== omitClaimId);
238
+ let runningIds = new Set();
239
+ let attachedIds = new Set();
240
+ if (driver && occupied.some((slot) => slot.vmId != null)) {
241
+ try {
242
+ const vms = await driver.listVms();
243
+ attachedIds = new Set(vms.map((vm) => String(vm.id)));
244
+ runningIds = new Set(vms.filter((vm) => vm.status === "running").map((vm) => String(vm.id)));
245
+ }
246
+ catch { /* Unknown attachment remains conservatively reserved. */ }
207
247
  }
248
+ const reservedMemoryMb = occupied.reduce((sum, slot) => sum + (slot.vmId != null && runningIds.has(String(slot.vmId)) ? 0 : slot.memoryMb), 0);
249
+ const reservedDiskGb = occupied.reduce((sum, slot) => sum + (slot.vmId != null && attachedIds.has(String(slot.vmId)) ? 0 : slot.allocatedDiskGb ?? 10), 0);
250
+ return {
251
+ status: "online",
252
+ freeMemoryMb: Math.max(0, capacity.memoryFreeBytes / (1024 * 1024) - reservedMemoryMb),
253
+ freeDiskGb: Math.max(0, capacity.storageFreeBytes / (1024 ** 3) - reservedDiskGb),
254
+ totalMemoryMb: capacity.memoryTotalBytes / (1024 * 1024),
255
+ totalDiskGb: capacity.storageTotalBytes / (1024 ** 3),
256
+ cpuTotalCores: capacity.cpuTotalCores,
257
+ observedAt,
258
+ };
208
259
  }
260
+ catch {
261
+ return unknown;
262
+ }
263
+ }
264
+ availableResourceUnits(resources, memoryMb, diskGb = 10, cpuCores = 1) {
265
+ if (resources.status !== "online" || !Number.isFinite(memoryMb) || memoryMb <= 0
266
+ || !Number.isFinite(diskGb) || diskGb <= 0 || !Number.isFinite(cpuCores)
267
+ || cpuCores <= 0 || cpuCores > resources.cpuTotalCores)
268
+ return 0;
269
+ const units = Math.floor(Math.min(resources.freeMemoryMb / memoryMb, resources.freeDiskGb / diskGb));
270
+ return Number.isSafeInteger(units) && units >= 0 ? units : 0;
209
271
  }
210
272
  /**
211
273
  * Normalizes host ID strings (e.g. mapping aliases like proxmox-minis-lab -> proxmox-mini).
@@ -299,6 +361,30 @@ export class SlotManager {
299
361
  async claimSlot(request) {
300
362
  return this.mutex.runExclusive(async () => this.claimSlotInternal(request));
301
363
  }
364
+ /** Recheck the exact size immediately before public claim creation/provisioning. */
365
+ async assertProvisionResources(hostId, request) {
366
+ return this.mutex.runExclusive(async () => {
367
+ const host = this.hosts.get(this.normalizeHostId(hostId));
368
+ if (!host)
369
+ throw new InvalidSlotOperationError(`Unknown provisioning host '${hostId}'.`);
370
+ const claim = request.claimId ? Array.from(this.slots.values())
371
+ .find((slot) => slot.claimId === request.claimId) : undefined;
372
+ if (request.claimId && (!claim || claim.hostId !== host.hostId
373
+ || (claim.status !== "CLAIMED" && claim.status !== "active")
374
+ || !claim.expiresAt || !Number.isFinite(Date.parse(claim.expiresAt))
375
+ || Date.parse(claim.expiresAt) <= Date.now()
376
+ || (request.linearIssue && claim.linearIssue && request.linearIssue !== claim.linearIssue))) {
377
+ throw new InvalidSlotOperationError("Provisioning requires the exact active, unexpired claim on the requested host and issue.");
378
+ }
379
+ if (claim && (request.memoryMb > claim.memoryMb || request.diskGb > (claim.allocatedDiskGb ?? 10))) {
380
+ throw new InvalidSlotOperationError("Requested guest size exceeds the existing lease reservation; acquire a size-matched claim.");
381
+ }
382
+ const resources = await this.readHostResources(host, request.storage, request.claimId);
383
+ if (this.availableResourceUnits(resources, request.memoryMb, request.diskGb, request.cpuCores) < 1) {
384
+ throw new CapacityExceededError(`Host '${host.hostId}' lacks verified resources for the requested guest size (capacity ${resources.status}).`, host.hostId);
385
+ }
386
+ });
387
+ }
302
388
  async claimSlotInternal(request) {
303
389
  // 1. Reclaim any expired leases first
304
390
  this.sweepExpiredSlotsInternal();
@@ -310,7 +396,7 @@ export class SlotManager {
310
396
  if (durationMinutes < 1 || durationMinutes > 1440) {
311
397
  throw new InvalidSlotOperationError(`Claim duration must be between 1 and 1440 minutes (requested: ${durationMinutes}).`);
312
398
  }
313
- const rawRequestedHost = request.host || request.targetHost;
399
+ const rawRequestedHost = request.host && request.host !== "auto" ? request.host : request.targetHost || request.host;
314
400
  const requestedHost = rawRequestedHost ? this.normalizeHostId(rawRequestedHost) : undefined;
315
401
  const hypervisorTypePref = request.hypervisorType || "any";
316
402
  const allowEsxi = request.allowEsxiSlots ?? this.allowEsxiSlots;
@@ -327,162 +413,72 @@ export class SlotManager {
327
413
  throw new HypervisorSlotUnsupportedError("Slot leasing is not supported on ESXi hypervisors. Appliance slots are exclusively managed on Proxmox VE (proxmox-lab and proxmox-mini).", requestedHost, "esxi");
328
414
  }
329
415
  }
330
- let targetSlot;
331
- if (requestedHost && requestedHost !== "auto") {
332
- // Specific host claim
333
- const hostConfig = this.hosts.get(requestedHost);
334
- if (!hostConfig) {
335
- const validHosts = Array.from(this.hosts.keys()).join(", ");
336
- throw new InvalidSlotOperationError(`Unknown host '${requestedHost}' for slot allocation. Available: [${validHosts}]`);
337
- }
338
- // ESXi Datastore check (>= 10GB free required)
339
- if (hostConfig.hypervisorType === "esxi") {
340
- const freeDisk = hostConfig.freeDiskGb ?? 100;
341
- if (freeDisk < 10) {
342
- throw new DatastoreCapacityExceededError(requestedHost, 10, freeDisk);
343
- }
344
- }
345
- const hostSlots = Array.from(this.slots.values())
346
- .filter((s) => s.hostId === requestedHost)
347
- .sort((a, b) => a.slotIndex - b.slotIndex);
348
- let availableSlot = hostSlots.find((s) => s.status === "AVAILABLE");
349
- // Dynamic slot expansion when box pure capacity exceeds pre-allocated slots
350
- if (!availableSlot) {
351
- const pureSlots = calculatePureCapacity(hostConfig);
352
- if (hostSlots.length < pureSlots) {
353
- const nextIndex = hostSlots.length + 1;
354
- const slotId = `slot-${requestedHost}-${nextIndex}`;
355
- availableSlot = {
356
- slotId,
357
- hostId: requestedHost,
358
- hostName: hostConfig.name,
359
- hypervisorType: hostConfig.hypervisorType,
360
- slotIndex: nextIndex,
361
- memoryMb: hostConfig.slotSizeMb || 8192,
362
- allocatedMemoryMb: hostConfig.slotSizeMb || 8192,
363
- allocatedMemoryBytes: (hostConfig.slotSizeMb || 8192) * 1024 * 1024,
364
- status: "AVAILABLE",
365
- claimId: null,
366
- claimedAt: null,
367
- expiresAt: null,
368
- durationMinutes: null,
369
- ttlMinutes: null,
370
- ttlSeconds: null,
371
- tag: null,
372
- label: null,
373
- workloadType: null,
374
- targetVersion: null,
375
- branch: null,
376
- customTags: [],
377
- requester: null,
378
- linearIssue: null,
379
- sessionId: null,
380
- purpose: null,
381
- vmId: null,
382
- vmName: null,
383
- metadata: {}
384
- };
385
- this.slots.set(slotId, availableSlot);
386
- hostSlots.push(availableSlot);
387
- }
388
- }
389
- if (!availableSlot && request.purgeIfFull) {
390
- const purgeRes = await this.purgeSlotsInternal({
391
- host: requestedHost,
392
- destroyVms: true,
393
- includeOrphans: true,
394
- dryRun: false
395
- });
396
- if (purgeRes.purgedSlotsCount > 0 || purgeRes.destroyedVmsCount > 0) {
397
- const recheckedSlots = Array.from(this.slots.values())
398
- .filter((s) => s.hostId === requestedHost)
399
- .sort((a, b) => a.slotIndex - b.slotIndex);
400
- availableSlot = recheckedSlots.find((s) => s.status === "AVAILABLE");
401
- }
402
- }
403
- if (!availableSlot) {
404
- const allocatedCount = hostSlots.filter((s) => s.status === "CLAIMED" || s.status === "active").length;
405
- const hostLimit = hostConfig.maxSlots ?? calculatePureCapacity(hostConfig);
406
- if (requestedHost === "proxmox-lab") {
407
- 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", hostLimit, allocatedCount);
408
- }
409
- else if (requestedHost === "proxmox-mini") {
410
- throw new CapacityExceededError(`Host 'proxmox-mini' has reached its hard quota limit of 4 concurrent 8GB slots (32GB allocated).`, "proxmox-mini", hostLimit, allocatedCount);
411
- }
412
- else {
413
- throw new CapacityExceededError(`Host '${requestedHost}' has reached its hard quota limit of ${hostLimit} concurrent slots.`, requestedHost, hostLimit, allocatedCount);
414
- }
416
+ const memoryMb = request.slotSizeMb ?? 8192;
417
+ const diskGb = request.diskGb ?? 10;
418
+ const cpuCores = request.cpuCores ?? 1;
419
+ if (!Number.isFinite(memoryMb) || memoryMb <= 0 || !Number.isFinite(diskGb) || diskGb <= 0) {
420
+ throw new InvalidSlotOperationError("Requested memory and disk must be positive finite sizes.");
421
+ }
422
+ const diskForHost = (hostId) => request.hostDiskGb?.[hostId] ?? diskGb;
423
+ if (request.hostDiskGb && Object.values(request.hostDiskGb).some((size) => !Number.isFinite(size) || size <= 0)) {
424
+ throw new InvalidSlotOperationError("Inherited per-host disk profiles must be positive finite sizes.");
425
+ }
426
+ const candidates = Array.from(this.hosts.values()).filter((host) => {
427
+ if (request.hostDiskGb && !(host.hostId in request.hostDiskGb))
428
+ return false;
429
+ if (requestedHost && requestedHost !== "auto" && host.hostId !== requestedHost)
430
+ return false;
431
+ if (host.hypervisorType === "esxi" && !allowEsxi)
432
+ return false;
433
+ if (hypervisorTypePref === "proxmox" && host.hypervisorType !== "proxmox")
434
+ return false;
435
+ if (hypervisorTypePref === "esxi" && host.hypervisorType !== "esxi")
436
+ return false;
437
+ return true;
438
+ });
439
+ if (requestedHost && requestedHost !== "auto" && !this.hosts.has(requestedHost)) {
440
+ throw new InvalidSlotOperationError(`Unknown host '${requestedHost}' for slot allocation.`);
441
+ }
442
+ const readCandidates = () => Promise.all(candidates.map(async (host) => ({
443
+ host, diskGb: diskForHost(host.hostId), resources: await this.readHostResources(host, request.storage),
444
+ })));
445
+ let readings = await readCandidates();
446
+ let eligible = readings.filter(({ resources, diskGb }) => this.availableResourceUnits(resources, memoryMb, diskGb, cpuCores) > 0);
447
+ if (eligible.length === 0 && request.purgeIfFull) {
448
+ // Reclaim only permitted, healthy hosts on which the requested size can
449
+ // physically fit. Unknown/oversized requests never trigger cleanup.
450
+ const reclaimableHosts = readings.filter(({ resources, diskGb }) => resources.status === "online"
451
+ && resources.totalMemoryMb >= memoryMb && resources.totalDiskGb >= diskGb
452
+ && Number.isSafeInteger(cpuCores) && cpuCores > 0 && resources.cpuTotalCores >= cpuCores);
453
+ for (const { host } of reclaimableHosts) {
454
+ await this.purgeSlotsInternal({ host: host.hostId, destroyVms: true, includeOrphans: true, dryRun: false });
455
+ readings = await readCandidates();
456
+ eligible = readings.filter(({ resources, diskGb }) => this.availableResourceUnits(resources, memoryMb, diskGb, cpuCores) > 0);
457
+ if (eligible.length > 0)
458
+ break;
415
459
  }
416
- targetSlot = availableSlot;
417
460
  }
418
- else {
419
- // Homogeneous-fleet auto-placement. Every eligible host is a
420
- // peer at the request boundary; production uses system randomness,
421
- // while tests may supply an explicit seed for reproducible placement.
422
- // Filter candidate hosts based on hypervisor preference (excluding ESXi unless allowEsxi)
423
- const candidateHostConfigs = Array.from(this.hosts.values()).filter((h) => {
424
- const pureSlots = calculatePureCapacity({ ...h, allowEsxiSlots: allowEsxi });
425
- if (h.hypervisorType === "esxi" && !allowEsxi)
426
- return false;
427
- if (pureSlots === 0 && (!allowEsxi || h.hypervisorType !== "esxi"))
428
- return false;
429
- if (hypervisorTypePref === "proxmox" && h.hypervisorType !== "proxmox")
430
- return false;
431
- if (hypervisorTypePref === "esxi" && h.hypervisorType !== "esxi")
432
- return false;
433
- return true;
434
- });
435
- // Evaluate available slots and datastore health per host
436
- const hostStats = candidateHostConfigs.map((h) => {
437
- const hostSlots = Array.from(this.slots.values()).filter((s) => s.hostId === h.hostId);
438
- const availableSlots = hostSlots.filter((s) => s.status === "AVAILABLE").length;
439
- const freeDiskGb = h.freeDiskGb ?? 100;
440
- const datastoreOk = h.hypervisorType !== "esxi" || freeDiskGb >= 10;
441
- return { host: h, availableSlots, hostSlots, datastoreOk };
442
- });
443
- let totalAvailable = hostStats.reduce((sum, h) => sum + (h.datastoreOk ? h.availableSlots : 0), 0);
444
- if (totalAvailable <= 0 && request.purgeIfFull) {
445
- const purgeRes = await this.purgeSlotsInternal({
446
- destroyVms: true,
447
- includeOrphans: true,
448
- dryRun: false
449
- });
450
- if (purgeRes.purgedSlotsCount > 0 || purgeRes.destroyedVmsCount > 0) {
451
- for (const h of hostStats) {
452
- const hSlots = Array.from(this.slots.values()).filter((s) => s.hostId === h.host.hostId);
453
- h.availableSlots = hSlots.filter((s) => s.status === "AVAILABLE").length;
454
- h.hostSlots = hSlots;
455
- }
456
- totalAvailable = hostStats.reduce((sum, h) => sum + (h.datastoreOk ? h.availableSlots : 0), 0);
461
+ if (eligible.length === 0) {
462
+ const allocated = Array.from(this.slots.values()).filter((slot) => candidates.some((host) => host.hostId === slot.hostId) && slot.status !== "AVAILABLE").length;
463
+ if (requestedHost && requestedHost !== "auto") {
464
+ const resources = readings[0]?.resources;
465
+ if (resources?.status === "online" && resources.freeDiskGb < diskGb) {
466
+ throw new DatastoreCapacityExceededError(requestedHost, diskGb, resources.freeDiskGb);
457
467
  }
468
+ throw new CapacityExceededError(`Host '${requestedHost}' lacks verified free resources for ${memoryMb} MB RAM and ${diskGb} GB disk (capacity ${resources?.status ?? "unknown"}).`, requestedHost, allocated, allocated);
458
469
  }
459
- if (totalAvailable <= 0) {
460
- if (hypervisorTypePref === "proxmox") {
461
- throw new CapacityExceededError("All Proxmox cluster slots are currently allocated (2/2 on proxmox-lab, 4/4 on proxmox-mini).", "proxmox-cluster", 6, 6);
462
- }
463
- throw new GlobalCapacityExceededError("All 6 cluster slots are currently allocated (2/2 on proxmox-lab, 4/4 on proxmox-mini).", 6, 6);
464
- }
465
- const validCandidates = hostStats
466
- .filter((h) => h.datastoreOk && h.availableSlots > 0)
467
- .sort((a, b) => a.host.hostId.localeCompare(b.host.hostId));
468
- const candidateIndex = request.placementSeed
469
- ? createHash("sha256")
470
- .update(request.placementSeed)
471
- .digest()
472
- .readUInt32BE(0) % validCandidates.length
473
- : randomInt(validCandidates.length);
474
- const winningHostStat = validCandidates[candidateIndex];
475
- if (!winningHostStat) {
476
- throw new GlobalCapacityExceededError("All 6 cluster slots are currently allocated (2/2 on proxmox-lab, 4/4 on proxmox-mini).", 6, 6);
477
- }
478
- const availableSlotsOnWinner = winningHostStat.hostSlots
479
- .filter((s) => s.status === "AVAILABLE")
480
- .sort((a, b) => a.slotIndex - b.slotIndex);
481
- targetSlot = availableSlotsOnWinner[0];
482
- }
483
- if (!targetSlot) {
484
- throw new GlobalCapacityExceededError("All 6 cluster slots are currently allocated (2/2 on proxmox-lab, 4/4 on proxmox-mini).", 6, 6);
470
+ throw new GlobalCapacityExceededError(`No eligible host has verified free resources for ${memoryMb} MB RAM and ${diskGb} GB disk.`, allocated, allocated);
485
471
  }
472
+ eligible.sort((a, b) => a.host.hostId.localeCompare(b.host.hostId));
473
+ const preferredIndex = request.preferredHost ? eligible.findIndex(({ host }) => host.hostId === this.normalizeHostId(request.preferredHost)) : -1;
474
+ const index = preferredIndex >= 0 ? preferredIndex : request.placementSeed
475
+ ? createHash("sha256").update(request.placementSeed).digest().readUInt32BE(0) % eligible.length
476
+ : randomInt(eligible.length);
477
+ const targetSlot = this.createAvailableSlot(eligible[index].host);
478
+ targetSlot.memoryMb = memoryMb;
479
+ targetSlot.allocatedMemoryMb = memoryMb;
480
+ targetSlot.allocatedMemoryBytes = memoryMb * 1024 * 1024;
481
+ targetSlot.allocatedDiskGb = eligible[index].diskGb;
486
482
  // Assign claim details
487
483
  const claimId = `claim-${randomUUID().substring(0, 8)}`;
488
484
  const now = new Date();
@@ -549,6 +545,7 @@ export class SlotManager {
549
545
  memoryMb: targetSlot.memoryMb,
550
546
  allocatedMemoryMb: targetSlot.memoryMb,
551
547
  allocatedMemoryBytes: targetSlot.memoryMb * 1024 * 1024,
548
+ allocatedDiskGb: targetSlot.allocatedDiskGb,
552
549
  tag: targetSlot.tag,
553
550
  label: targetSlot.label || undefined,
554
551
  workloadType: targetSlot.workloadType || undefined,
@@ -840,6 +837,23 @@ export class SlotManager {
840
837
  async listSlots(options = {}) {
841
838
  return this.mutex.runExclusive(async () => {
842
839
  this.sweepExpiredSlotsInternal();
840
+ // Available slot IDs are a compatibility projection of current resources,
841
+ // never a preallocated admission pool. Existing occupied leases are untouched.
842
+ for (const host of this.hosts.values()) {
843
+ const resources = await this.readHostResources(host);
844
+ const additional = host.hypervisorType === "esxi" && !this.allowEsxiSlots ? 0
845
+ : this.availableResourceUnits(resources, host.slotSizeMb);
846
+ const available = Array.from(this.slots.values()).filter((slot) => slot.hostId === host.hostId && slot.status === "AVAILABLE");
847
+ for (const slot of available.slice(additional))
848
+ this.slots.delete(slot.slotId);
849
+ for (let index = available.length; index < additional; index++)
850
+ this.createAvailableSlot(host, false);
851
+ for (const slot of available.slice(0, additional)) {
852
+ slot.memoryMb = host.slotSizeMb;
853
+ slot.allocatedMemoryMb = host.slotSizeMb;
854
+ slot.allocatedMemoryBytes = host.slotSizeMb * 1024 * 1024;
855
+ }
856
+ }
843
857
  const now = Date.now();
844
858
  const results = [];
845
859
  const filterHost = options.host || options.targetHost;
@@ -932,6 +946,9 @@ export class SlotManager {
932
946
  * List host capacities or global cluster summary.
933
947
  */
934
948
  async listHosts(hostId) {
949
+ return this.mutex.runExclusive(() => this.listHostsInternal(hostId));
950
+ }
951
+ async listHostsInternal(hostId) {
935
952
  this.sweepExpiredSlotsInternal();
936
953
  const normalizedHostId = hostId && hostId !== "all" ? this.normalizeHostId(hostId) : hostId;
937
954
  // If specific ESXi host requested and not registered, auto-register
@@ -955,10 +972,12 @@ export class SlotManager {
955
972
  return { ...s, remainingTtlSeconds };
956
973
  })
957
974
  .sort((a, b) => a.slotIndex - b.slotIndex);
958
- const activeSlots = hostSlots.filter((s) => s.status === "CLAIMED" || s.status === "active");
975
+ const activeSlots = hostSlots.filter((s) => s.status !== "AVAILABLE");
959
976
  const allocatedSlots = activeSlots.length;
960
- const hostMaxSlots = config.maxSlots ?? calculatePureCapacity(config);
961
- const availableSlots = Math.max(0, hostMaxSlots - allocatedSlots);
977
+ const resources = await this.readHostResources(config);
978
+ const availableSlots = config.hypervisorType === "esxi" && !this.allowEsxiSlots ? 0
979
+ : this.availableResourceUnits(resources, config.slotSizeMb);
980
+ const hostMaxSlots = allocatedSlots + availableSlots;
962
981
  clusterMaxSlots += hostMaxSlots;
963
982
  clusterAllocatedSlots += allocatedSlots;
964
983
  clusterAvailableSlots += availableSlots;
@@ -966,15 +985,18 @@ export class SlotManager {
966
985
  hostId: id,
967
986
  name: config.name,
968
987
  hypervisorType: config.hypervisorType,
969
- totalMemoryMb: config.totalMemoryMb,
970
- reservedHeadroomMb: config.reservedHeadroomMb,
988
+ totalMemoryMb: resources.totalMemoryMb,
989
+ reservedHeadroomMb: 0,
990
+ admissionBasis: "physical_resources",
991
+ operationalStatus: resources.status,
992
+ capacityObservedAt: resources.observedAt,
971
993
  maxSlots: hostMaxSlots,
972
994
  allocatedSlots,
973
995
  availableSlots,
974
996
  slotSizeMb: config.slotSizeMb,
975
- freeDiskGb: config.freeDiskGb,
976
- totalDiskGb: config.totalDiskGb,
977
- datastoreFreeBytes: config.datastoreFreeBytes,
997
+ freeDiskGb: resources.freeDiskGb,
998
+ totalDiskGb: resources.totalDiskGb,
999
+ datastoreFreeBytes: resources.freeDiskGb * 1024 ** 3,
978
1000
  activeSlots
979
1001
  };
980
1002
  }
@@ -1345,23 +1367,6 @@ export class SlotManager {
1345
1367
  const targetHyp = params.hypervisorType && params.hypervisorType !== "all" && params.hypervisorType !== "any"
1346
1368
  ? params.hypervisorType
1347
1369
  : undefined;
1348
- // Sync with live capacity if hypervisorManager is present
1349
- if (this.hypervisorManager) {
1350
- try {
1351
- const liveCaps = await this.hypervisorManager.listAllHostsCapacity();
1352
- for (const cap of liveCaps) {
1353
- const hid = this.normalizeHostId(cap.name);
1354
- const cfg = this.hosts.get(hid);
1355
- if (cfg) {
1356
- cfg.totalMemoryMb = Math.round(cap.memoryTotalBytes / (1024 * 1024));
1357
- cfg.freeDiskGb = Math.round(cap.storageFreeBytes / (1024 * 1024 * 1024));
1358
- }
1359
- }
1360
- }
1361
- catch {
1362
- // fallback to registered configs
1363
- }
1364
- }
1365
1370
  // Gather candidate hosts
1366
1371
  const allCandidates = [];
1367
1372
  for (const [hid, hostConfig] of this.hosts.entries()) {
@@ -1373,15 +1378,17 @@ export class SlotManager {
1373
1378
  continue;
1374
1379
  if (params.excludeHosts && params.excludeHosts.some((ex) => this.normalizeHostId(ex) === hid))
1375
1380
  continue;
1376
- if (params.minFreeDiskGb && (hostConfig.freeDiskGb ?? 0) < params.minFreeDiskGb)
1381
+ const resources = await this.readHostResources(hostConfig, params.storage);
1382
+ if (params.minFreeDiskGb && resources.freeDiskGb < params.minFreeDiskGb)
1377
1383
  continue;
1378
1384
  const hostSlots = Array.from(this.slots.values()).filter((s) => s.hostId === hid);
1379
- const availCount = hostSlots.filter((s) => s.status === "AVAILABLE").length;
1380
- const pureCapacity = calculatePureCapacity({ ...hostConfig, allowEsxiSlots: allowEsxi });
1381
- const freeDisk = hostConfig.freeDiskGb ?? 100;
1382
- const totalMem = hostConfig.totalMemoryMb;
1383
- const reserved = hostConfig.reservedHeadroomMb || 0;
1384
- const freeMem = Math.max(0, totalMem - reserved - (hostSlots.length - availCount) * (hostConfig.slotSizeMb || 8192));
1385
+ const memoryMb = params.requiredMemoryGb !== undefined ? params.requiredMemoryGb * 1024 : hostConfig.slotSizeMb;
1386
+ const diskGb = params.diskGb ?? params.minFreeDiskGb ?? 10;
1387
+ const availCount = this.availableResourceUnits(resources, memoryMb, diskGb, params.cpuCores);
1388
+ const pureCapacity = hostSlots.filter((s) => s.status !== "AVAILABLE").length + availCount;
1389
+ const freeDisk = resources.freeDiskGb;
1390
+ const totalMem = resources.totalMemoryMb;
1391
+ const freeMem = resources.freeMemoryMb;
1385
1392
  // Host tiering: proxmox-mini (Tier 1 Primary), proxmox-lab (Tier 2 Secondary), ESXi (Tier 3 Fallback)
1386
1393
  let tier = "secondary";
1387
1394
  let tierBoost = 10000;
@@ -1407,15 +1414,17 @@ export class SlotManager {
1407
1414
  hostId: hid,
1408
1415
  name: hostConfig.name,
1409
1416
  hypervisorType: hostConfig.hypervisorType || (hid.startsWith("esxi") ? "esxi" : "proxmox"),
1410
- status: "online",
1417
+ status: resources.status === "online" ? "online" : "offline",
1411
1418
  tier,
1412
1419
  availableSlots: availCount,
1413
1420
  totalPureSlots: pureCapacity,
1414
1421
  freeMemoryMb: freeMem,
1415
1422
  totalMemoryMb: totalMem,
1416
1423
  freeDiskGb: freeDisk,
1424
+ totalDiskGb: resources.totalDiskGb,
1425
+ cpuTotalCores: resources.cpuTotalCores,
1417
1426
  cpuUsedPercent: 0,
1418
- runningVms: hostSlots.length - availCount,
1427
+ runningVms: hostSlots.filter((slot) => slot.status !== "AVAILABLE").length,
1419
1428
  score,
1420
1429
  recommendationReason
1421
1430
  });
@@ -1479,6 +1488,10 @@ export class SlotManager {
1479
1488
  branch: params.branch,
1480
1489
  host: winningCandidate.hostId,
1481
1490
  durationMinutes: params.durationMinutes,
1491
+ slotSizeMb: params.requiredMemoryGb !== undefined ? params.requiredMemoryGb * 1024 : undefined,
1492
+ diskGb: params.diskGb ?? params.minFreeDiskGb,
1493
+ cpuCores: params.cpuCores,
1494
+ storage: params.storage,
1482
1495
  allowEsxiSlots: allowEsxi,
1483
1496
  purpose: `Auto-claimed via find_slot on ${winningCandidate.hostId}`
1484
1497
  });
@@ -1588,6 +1601,10 @@ export class SlotManager {
1588
1601
  branch: params.branch,
1589
1602
  host: targetHost || "auto",
1590
1603
  durationMinutes: params.durationMinutes,
1604
+ slotSizeMb: params.requiredMemoryGb !== undefined ? params.requiredMemoryGb * 1024 : undefined,
1605
+ diskGb: params.diskGb ?? params.minFreeDiskGb,
1606
+ cpuCores: params.cpuCores,
1607
+ storage: params.storage,
1591
1608
  allowEsxiSlots: allowEsxi,
1592
1609
  purpose: `Auto-reclaimed and claimed via find_slot`
1593
1610
  });