@calltelemetry/ct-lab-mcp 0.1.2 → 0.1.3

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 (63) hide show
  1. package/dist/src/appliance/commands.d.ts +4 -0
  2. package/dist/src/appliance/commands.d.ts.map +1 -1
  3. package/dist/src/appliance/commands.js +32 -0
  4. package/dist/src/appliance/commands.js.map +1 -1
  5. package/dist/src/appliance/mock.d.ts.map +1 -1
  6. package/dist/src/appliance/mock.js +42 -0
  7. package/dist/src/appliance/mock.js.map +1 -1
  8. package/dist/src/appliance/types.d.ts +8 -1
  9. package/dist/src/appliance/types.d.ts.map +1 -1
  10. package/dist/src/appliance/types.js.map +1 -1
  11. package/dist/src/server.d.ts +3 -1
  12. package/dist/src/server.d.ts.map +1 -1
  13. package/dist/src/server.js +14 -4
  14. package/dist/src/server.js.map +1 -1
  15. package/dist/src/slot/gc.d.ts +11 -2
  16. package/dist/src/slot/gc.d.ts.map +1 -1
  17. package/dist/src/slot/gc.js +20 -2
  18. package/dist/src/slot/gc.js.map +1 -1
  19. package/dist/src/slot/manager.d.ts +34 -3
  20. package/dist/src/slot/manager.d.ts.map +1 -1
  21. package/dist/src/slot/manager.js +572 -3
  22. package/dist/src/slot/manager.js.map +1 -1
  23. package/dist/src/slot/types.d.ts +96 -1
  24. package/dist/src/slot/types.d.ts.map +1 -1
  25. package/dist/src/slot/types.js.map +1 -1
  26. package/dist/src/telemetry/index.d.ts +3 -0
  27. package/dist/src/telemetry/index.d.ts.map +1 -0
  28. package/dist/src/telemetry/index.js +3 -0
  29. package/dist/src/telemetry/index.js.map +1 -0
  30. package/dist/src/telemetry/registry.d.ts +43 -0
  31. package/dist/src/telemetry/registry.d.ts.map +1 -0
  32. package/dist/src/telemetry/registry.js +146 -0
  33. package/dist/src/telemetry/registry.js.map +1 -0
  34. package/dist/src/telemetry/types.d.ts +37 -0
  35. package/dist/src/telemetry/types.d.ts.map +1 -0
  36. package/dist/src/telemetry/types.js +6 -0
  37. package/dist/src/telemetry/types.js.map +1 -0
  38. package/dist/src/tools/appliance-exec.js +1 -1
  39. package/dist/src/tools/appliance-exec.js.map +1 -1
  40. package/dist/src/tools/index.d.ts +3 -1
  41. package/dist/src/tools/index.d.ts.map +1 -1
  42. package/dist/src/tools/index.js +19 -8
  43. package/dist/src/tools/index.js.map +1 -1
  44. package/dist/src/tools/list-hosts.d.ts +2 -2
  45. package/dist/src/tools/list-inventory.d.ts +3 -3
  46. package/dist/src/tools/provision-tools.d.ts +122 -21
  47. package/dist/src/tools/provision-tools.d.ts.map +1 -1
  48. package/dist/src/tools/provision-tools.js +384 -1
  49. package/dist/src/tools/provision-tools.js.map +1 -1
  50. package/dist/src/tools/purge-slots.d.ts +52 -0
  51. package/dist/src/tools/purge-slots.d.ts.map +1 -0
  52. package/dist/src/tools/purge-slots.js +107 -0
  53. package/dist/src/tools/purge-slots.js.map +1 -0
  54. package/dist/src/tools/slot-tools.d.ts +92 -30
  55. package/dist/src/tools/slot-tools.d.ts.map +1 -1
  56. package/dist/src/tools/slot-tools.js +67 -5
  57. package/dist/src/tools/slot-tools.js.map +1 -1
  58. package/dist/src/tools/vm-action.d.ts +22 -22
  59. package/dist/src/utils/formatters.d.ts +38 -4
  60. package/dist/src/utils/formatters.d.ts.map +1 -1
  61. package/dist/src/utils/formatters.js +179 -23
  62. package/dist/src/utils/formatters.js.map +1 -1
  63. package/package.json +1 -1
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * Global Slot Allocation & Capacity Partitioning Engine
3
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.
4
+ * atomic concurrency locking (AsyncMutex), TTL lease management, candidate slot ranking,
5
+ * structured metadata labeling/indexing, and automated expired slot purge & reclamation.
5
6
  */
6
7
  import { randomUUID } from "node:crypto";
7
8
  import { CapacityExceededError, GlobalCapacityExceededError, SlotNotFoundError, LeaseCeilingExceededError, InvalidSlotOperationError, DatastoreCapacityExceededError } from "./types.js";
8
9
  import { AsyncMutex } from "../utils/mutex.js";
10
+ import { defaultSafetyGuardrailEngine } from "../guardrails/safety.js";
11
+ import { telemetry } from "../telemetry/index.js";
9
12
  export * from "./types.js";
10
13
  const DEFAULT_HOST_CONFIGS = [
11
14
  {
@@ -49,9 +52,15 @@ export class SlotManager {
49
52
  mutex = new AsyncMutex();
50
53
  defaultDurationMinutes;
51
54
  proxmoxManager;
55
+ hypervisorManager;
56
+ guardrailEngine;
57
+ telemetry;
52
58
  constructor(options = {}) {
53
59
  this.defaultDurationMinutes = options.defaultDurationMinutes ?? 60;
54
60
  this.proxmoxManager = options.proxmoxManager;
61
+ this.hypervisorManager = options.hypervisorManager;
62
+ this.guardrailEngine = options.guardrailEngine || defaultSafetyGuardrailEngine;
63
+ this.telemetry = options.telemetryRegistry || telemetry;
55
64
  const hostConfigs = options.hosts && options.hosts.length > 0
56
65
  ? options.hosts
57
66
  : [...DEFAULT_HOST_CONFIGS];
@@ -62,6 +71,18 @@ export class SlotManager {
62
71
  this.registerHost(host);
63
72
  }
64
73
  }
74
+ /**
75
+ * Set or update hypervisor manager reference.
76
+ */
77
+ setHypervisorManager(hypervisorManager) {
78
+ this.hypervisorManager = hypervisorManager;
79
+ }
80
+ /**
81
+ * Set or update safety guardrail engine reference.
82
+ */
83
+ setGuardrailEngine(guardrailEngine) {
84
+ this.guardrailEngine = guardrailEngine;
85
+ }
65
86
  /**
66
87
  * Register a host and initialize its slot definitions.
67
88
  */
@@ -93,6 +114,11 @@ export class SlotManager {
93
114
  ttlMinutes: null,
94
115
  ttlSeconds: null,
95
116
  tag: null,
117
+ label: null,
118
+ workloadType: null,
119
+ targetVersion: null,
120
+ branch: null,
121
+ customTags: [],
96
122
  requester: null,
97
123
  linearIssue: null,
98
124
  sessionId: null,
@@ -112,6 +138,19 @@ export class SlotManager {
112
138
  return "proxmox-mini";
113
139
  return hostId;
114
140
  }
141
+ /**
142
+ * Normalizes tag inputs into array of strings.
143
+ */
144
+ normalizeTagsArray(tags) {
145
+ if (!tags)
146
+ return [];
147
+ if (Array.isArray(tags))
148
+ return tags.map((t) => String(t).trim()).filter(Boolean);
149
+ if (typeof tags === "string") {
150
+ return tags.split(/[;, ]+/).map((t) => t.trim()).filter(Boolean);
151
+ }
152
+ return [];
153
+ }
115
154
  /**
116
155
  * Internal lazy expiration sweep (must be called inside mutex or synchronous method).
117
156
  */
@@ -121,6 +160,25 @@ export class SlotManager {
121
160
  if ((slot.status === "CLAIMED" || slot.status === "active") && slot.expiresAt) {
122
161
  const expiresTime = new Date(slot.expiresAt).getTime();
123
162
  if (expiresTime <= now) {
163
+ // Emit expired telemetry event
164
+ this.telemetry.emit({
165
+ event: "slot.expired",
166
+ actor: slot.requester || slot.sessionId || "system",
167
+ status: "success",
168
+ linearIssue: slot.linearIssue || undefined,
169
+ slotId: slot.slotId,
170
+ claimId: slot.claimId || undefined,
171
+ vmId: slot.vmId || undefined,
172
+ vmName: slot.vmName || undefined,
173
+ hostId: slot.hostId,
174
+ hypervisorType: slot.hypervisorType,
175
+ metadata: {
176
+ expiredAt: new Date(now).toISOString(),
177
+ scheduledExpiresAt: slot.expiresAt,
178
+ label: slot.label,
179
+ workloadType: slot.workloadType
180
+ }
181
+ });
124
182
  slot.status = "AVAILABLE";
125
183
  slot.claimId = null;
126
184
  slot.claimedAt = null;
@@ -129,6 +187,11 @@ export class SlotManager {
129
187
  slot.ttlMinutes = null;
130
188
  slot.ttlSeconds = null;
131
189
  slot.tag = null;
190
+ slot.label = null;
191
+ slot.workloadType = null;
192
+ slot.targetVersion = null;
193
+ slot.branch = null;
194
+ slot.customTags = [];
132
195
  slot.requester = null;
133
196
  slot.linearIssue = null;
134
197
  slot.sessionId = null;
@@ -259,6 +322,20 @@ export class SlotManager {
259
322
  const now = new Date();
260
323
  const claimedAt = now.toISOString();
261
324
  const expiresAt = new Date(now.getTime() + durationMinutes * 60 * 1000).toISOString();
325
+ const linearIssue = request.linearIssue || (request.tag && request.tag.startsWith("CTUAT-") ? request.tag : null);
326
+ // Normalize custom tags
327
+ const customTags = [
328
+ ...this.normalizeTagsArray(request.customTags),
329
+ ...this.normalizeTagsArray(request.tags)
330
+ ];
331
+ // Deduplicate tags
332
+ const uniqueTags = Array.from(new Set(customTags));
333
+ // Auto-assign human readable label if not provided
334
+ const label = request.label || (linearIssue
335
+ ? `[${linearIssue}] ${request.workloadType || "workload"}: Slot ${targetSlot.slotIndex}`
336
+ : request.purpose
337
+ ? `${request.purpose} (Slot ${targetSlot.slotIndex})`
338
+ : `${request.tag || tagInput} Lease`);
262
339
  targetSlot.status = "CLAIMED";
263
340
  targetSlot.claimId = claimId;
264
341
  targetSlot.claimedAt = claimedAt;
@@ -267,11 +344,35 @@ export class SlotManager {
267
344
  targetSlot.ttlMinutes = durationMinutes;
268
345
  targetSlot.ttlSeconds = durationMinutes * 60;
269
346
  targetSlot.tag = (request.tag || tagInput).trim();
347
+ targetSlot.label = label;
348
+ targetSlot.workloadType = request.workloadType || null;
349
+ targetSlot.targetVersion = request.targetVersion || null;
350
+ targetSlot.branch = request.branch || null;
351
+ targetSlot.customTags = uniqueTags;
270
352
  targetSlot.requester = request.requester || request.tag || null;
271
- targetSlot.linearIssue = request.linearIssue || (request.tag && request.tag.startsWith("CTUAT-") ? request.tag : null);
353
+ targetSlot.linearIssue = linearIssue;
272
354
  targetSlot.sessionId = request.sessionId || null;
273
355
  targetSlot.purpose = request.purpose || null;
274
356
  targetSlot.metadata = request.metadata || {};
357
+ // Emit audit telemetry
358
+ this.telemetry.emit({
359
+ event: "slot.claim",
360
+ actor: targetSlot.requester || targetSlot.sessionId || "unknown",
361
+ status: "success",
362
+ linearIssue: linearIssue || undefined,
363
+ slotId: targetSlot.slotId,
364
+ claimId,
365
+ hostId: targetSlot.hostId,
366
+ hypervisorType: targetSlot.hypervisorType,
367
+ metadata: {
368
+ label: targetSlot.label,
369
+ workloadType: targetSlot.workloadType,
370
+ targetVersion: targetSlot.targetVersion,
371
+ branch: targetSlot.branch,
372
+ durationMinutes,
373
+ expiresAt
374
+ }
375
+ });
275
376
  return {
276
377
  claimId,
277
378
  slotId: targetSlot.slotId,
@@ -283,6 +384,11 @@ export class SlotManager {
283
384
  allocatedMemoryMb: targetSlot.memoryMb,
284
385
  allocatedMemoryBytes: targetSlot.memoryMb * 1024 * 1024,
285
386
  tag: targetSlot.tag,
387
+ label: targetSlot.label || undefined,
388
+ workloadType: targetSlot.workloadType || undefined,
389
+ targetVersion: targetSlot.targetVersion || undefined,
390
+ branch: targetSlot.branch || undefined,
391
+ customTags: targetSlot.customTags.length > 0 ? targetSlot.customTags : undefined,
286
392
  requester: targetSlot.requester || undefined,
287
393
  linearIssue: targetSlot.linearIssue || undefined,
288
394
  claimedAt,
@@ -380,6 +486,23 @@ export class SlotManager {
380
486
  if (destroyVm && slot.vmId !== null && slot.vmId !== undefined) {
381
487
  destroyedVms.push(slot.vmId);
382
488
  }
489
+ // Emit telemetry for released slot
490
+ this.telemetry.emit({
491
+ event: "slot.release",
492
+ actor: requester || sessionId || slot.requester || "unknown",
493
+ status: "success",
494
+ linearIssue: slot.linearIssue || undefined,
495
+ slotId: slot.slotId,
496
+ claimId: slot.claimId || undefined,
497
+ vmId: slot.vmId || undefined,
498
+ vmName: slot.vmName || undefined,
499
+ hostId: slot.hostId,
500
+ hypervisorType: slot.hypervisorType,
501
+ metadata: {
502
+ freedMemoryMb: slot.memoryMb,
503
+ destroyVmRequested: Boolean(destroyVm)
504
+ }
505
+ });
383
506
  slot.status = "AVAILABLE";
384
507
  slot.claimId = null;
385
508
  slot.claimedAt = null;
@@ -388,6 +511,11 @@ export class SlotManager {
388
511
  slot.ttlMinutes = null;
389
512
  slot.ttlSeconds = null;
390
513
  slot.tag = null;
514
+ slot.label = null;
515
+ slot.workloadType = null;
516
+ slot.targetVersion = null;
517
+ slot.branch = null;
518
+ slot.customTags = [];
391
519
  slot.requester = null;
392
520
  slot.linearIssue = null;
393
521
  slot.sessionId = null;
@@ -438,6 +566,23 @@ export class SlotManager {
438
566
  slot.durationMinutes = totalDurationMinutes;
439
567
  slot.ttlMinutes = totalDurationMinutes;
440
568
  slot.ttlSeconds = totalDurationMinutes * 60;
569
+ // Emit telemetry for extension
570
+ this.telemetry.emit({
571
+ event: "slot.extend",
572
+ actor: sessionId || slot.requester || "unknown",
573
+ status: "success",
574
+ linearIssue: slot.linearIssue || undefined,
575
+ slotId: slot.slotId,
576
+ claimId,
577
+ hostId: slot.hostId,
578
+ hypervisorType: slot.hypervisorType,
579
+ metadata: {
580
+ extendedByMinutes: additionalMinutes,
581
+ previousExpiresAt,
582
+ newExpiresAt,
583
+ totalDurationMinutes
584
+ }
585
+ });
441
586
  return {
442
587
  claimId,
443
588
  slotId: slot.slotId,
@@ -451,7 +596,81 @@ export class SlotManager {
451
596
  });
452
597
  }
453
598
  /**
454
- * List slots with optional filtering and computed remaining TTL.
599
+ * Update metadata, label, workload type, or tags on an active slot lease.
600
+ */
601
+ async updateSlotMetadata(options) {
602
+ return this.mutex.runExclusive(async () => {
603
+ this.sweepExpiredSlotsInternal();
604
+ const { claimId, slotId } = options;
605
+ if (!claimId && !slotId) {
606
+ throw new InvalidSlotOperationError("Must provide 'claimId' or 'slotId' to update slot metadata.");
607
+ }
608
+ const slot = Array.from(this.slots.values()).find((s) => (claimId && s.claimId === claimId) || (slotId && s.slotId === slotId));
609
+ if (!slot || (slot.status !== "CLAIMED" && slot.status !== "active")) {
610
+ throw new SlotNotFoundError(`Active slot not found matching ${claimId ? `claimId '${claimId}'` : `slotId '${slotId}'`}.`);
611
+ }
612
+ const updatedFields = [];
613
+ if (options.label !== undefined) {
614
+ slot.label = options.label;
615
+ updatedFields.push("label");
616
+ }
617
+ if (options.workloadType !== undefined) {
618
+ slot.workloadType = options.workloadType;
619
+ updatedFields.push("workloadType");
620
+ }
621
+ if (options.targetVersion !== undefined) {
622
+ slot.targetVersion = options.targetVersion;
623
+ updatedFields.push("targetVersion");
624
+ }
625
+ if (options.branch !== undefined) {
626
+ slot.branch = options.branch;
627
+ updatedFields.push("branch");
628
+ }
629
+ if (options.customTags !== undefined) {
630
+ slot.customTags = this.normalizeTagsArray(options.customTags);
631
+ updatedFields.push("customTags");
632
+ }
633
+ if (options.linearIssue !== undefined) {
634
+ slot.linearIssue = options.linearIssue;
635
+ updatedFields.push("linearIssue");
636
+ }
637
+ if (options.purpose !== undefined) {
638
+ slot.purpose = options.purpose;
639
+ updatedFields.push("purpose");
640
+ }
641
+ if (options.metadata !== undefined) {
642
+ slot.metadata = { ...(slot.metadata || {}), ...options.metadata };
643
+ updatedFields.push("metadata");
644
+ }
645
+ // Emit telemetry for metadata update
646
+ this.telemetry.emit({
647
+ event: "slot.update_metadata",
648
+ actor: slot.requester || slot.sessionId || "unknown",
649
+ status: "success",
650
+ linearIssue: slot.linearIssue || undefined,
651
+ slotId: slot.slotId,
652
+ claimId: slot.claimId || undefined,
653
+ hostId: slot.hostId,
654
+ hypervisorType: slot.hypervisorType,
655
+ metadata: {
656
+ updatedFields,
657
+ label: slot.label,
658
+ workloadType: slot.workloadType,
659
+ targetVersion: slot.targetVersion,
660
+ branch: slot.branch,
661
+ customTags: slot.customTags
662
+ }
663
+ });
664
+ return {
665
+ success: true,
666
+ slot: { ...slot },
667
+ updatedFields,
668
+ message: `Successfully updated ${updatedFields.length} metadata field(s) on slot ${slot.slotId}.`
669
+ };
670
+ });
671
+ }
672
+ /**
673
+ * List slots with comprehensive multi-field filtering and computed remaining TTL.
455
674
  */
456
675
  async listSlots(options = {}) {
457
676
  return this.mutex.runExclusive(async () => {
@@ -460,6 +679,10 @@ export class SlotManager {
460
679
  const results = [];
461
680
  const filterHost = options.host || options.targetHost;
462
681
  const normalizedFilterHost = filterHost ? this.normalizeHostId(filterHost) : undefined;
682
+ const parsedTags = [
683
+ ...this.normalizeTagsArray(options.tags),
684
+ ...this.normalizeTagsArray(options.customTags)
685
+ ];
463
686
  for (const slot of this.slots.values()) {
464
687
  if (normalizedFilterHost && slot.hostId !== normalizedFilterHost)
465
688
  continue;
@@ -477,6 +700,51 @@ export class SlotManager {
477
700
  slot.hypervisorType !== options.hypervisorType) {
478
701
  continue;
479
702
  }
703
+ // Structured metadata filters
704
+ if (options.workloadType && slot.workloadType !== options.workloadType)
705
+ continue;
706
+ if (options.targetVersion && slot.targetVersion !== options.targetVersion)
707
+ continue;
708
+ if (options.branch && slot.branch !== options.branch)
709
+ continue;
710
+ if (options.label && (!slot.label || !slot.label.toLowerCase().includes(options.label.toLowerCase()))) {
711
+ continue;
712
+ }
713
+ // Custom tags matching (must match at least one if specified)
714
+ if (parsedTags.length > 0) {
715
+ const slotAllTags = [
716
+ ...(slot.customTags || []),
717
+ slot.tag,
718
+ slot.linearIssue
719
+ ].filter(Boolean).map((t) => String(t).toLowerCase());
720
+ const matchesTag = parsedTags.some((pt) => slotAllTags.some((st) => st.includes(pt.toLowerCase())));
721
+ if (!matchesTag)
722
+ continue;
723
+ }
724
+ // Free-text filter search across all string metadata fields
725
+ if (options.filter) {
726
+ const q = options.filter.toLowerCase().trim();
727
+ const searchable = [
728
+ slot.slotId,
729
+ slot.hostId,
730
+ slot.tag,
731
+ slot.label,
732
+ slot.workloadType,
733
+ slot.targetVersion,
734
+ slot.branch,
735
+ slot.linearIssue,
736
+ slot.purpose,
737
+ slot.requester,
738
+ slot.vmName,
739
+ slot.vmId ? String(slot.vmId) : null,
740
+ ...(slot.customTags || [])
741
+ ]
742
+ .filter(Boolean)
743
+ .join(" ")
744
+ .toLowerCase();
745
+ if (!searchable.includes(q))
746
+ continue;
747
+ }
480
748
  let remainingTtlSeconds = null;
481
749
  if ((slot.status === "CLAIMED" || slot.status === "active") && slot.expiresAt) {
482
750
  const diffMs = new Date(slot.expiresAt).getTime() - now;
@@ -586,6 +854,302 @@ export class SlotManager {
586
854
  return this.sweepExpiredSlotsInternal();
587
855
  });
588
856
  }
857
+ /**
858
+ * Purge expired, threshold-exceeded, or orphaned slots and reclaim hypervisor VM capacity.
859
+ * Gated by SafetyGuardrailEngine non-bypassable protections.
860
+ */
861
+ async purgeSlots(options = {}) {
862
+ return this.mutex.runExclusive(async () => {
863
+ const now = Date.now();
864
+ const dryRun = options.dryRun ?? false;
865
+ const destroyVms = options.destroyVms ?? true;
866
+ const force = options.force ?? false;
867
+ const includeOrphans = options.includeOrphans ?? false;
868
+ const targetHost = options.host && options.host !== "all" ? this.normalizeHostId(options.host) : undefined;
869
+ const targetHypervisor = options.hypervisorType && options.hypervisorType !== "all" && options.hypervisorType !== "any"
870
+ ? options.hypervisorType
871
+ : undefined;
872
+ const matchingSlots = [];
873
+ const reasons = new Map();
874
+ for (const slot of this.slots.values()) {
875
+ if (slot.status !== "CLAIMED" && slot.status !== "active")
876
+ continue;
877
+ if (targetHost && slot.hostId !== targetHost)
878
+ continue;
879
+ if (targetHypervisor && slot.hypervisorType !== targetHypervisor)
880
+ continue;
881
+ let shouldPurge = false;
882
+ let reason = "manual_purge";
883
+ const expiresTime = slot.expiresAt ? new Date(slot.expiresAt).getTime() : 0;
884
+ const claimedTime = slot.claimedAt ? new Date(slot.claimedAt).getTime() : 0;
885
+ const isExpired = expiresTime > 0 && expiresTime <= now;
886
+ if (options.linearIssue) {
887
+ const matchesLinear = slot.linearIssue === options.linearIssue || slot.tag === options.linearIssue;
888
+ if (matchesLinear) {
889
+ shouldPurge = true;
890
+ reason = isExpired ? "expired" : "linear_match";
891
+ }
892
+ }
893
+ else if (options.olderThanMinutes !== undefined) {
894
+ const ageMinutes = (now - claimedTime) / (60 * 1000);
895
+ if (ageMinutes >= options.olderThanMinutes || isExpired) {
896
+ shouldPurge = true;
897
+ reason = isExpired ? "expired" : "older_than_threshold";
898
+ }
899
+ }
900
+ else if (isExpired) {
901
+ shouldPurge = true;
902
+ reason = "expired";
903
+ }
904
+ else if (force) {
905
+ shouldPurge = true;
906
+ reason = "forced";
907
+ }
908
+ if (shouldPurge) {
909
+ matchingSlots.push(slot);
910
+ reasons.set(slot.slotId, reason);
911
+ }
912
+ }
913
+ const purgedSlots = [];
914
+ const destroyedVms = [];
915
+ let skippedProtectedCount = 0;
916
+ let reclaimedMemoryMb = 0;
917
+ for (const slot of matchingSlots) {
918
+ const reason = reasons.get(slot.slotId) || "manual_purge";
919
+ purgedSlots.push({
920
+ slotId: slot.slotId,
921
+ hostId: slot.hostId,
922
+ claimId: slot.claimId,
923
+ claimedAt: slot.claimedAt,
924
+ expiresAt: slot.expiresAt,
925
+ linearIssue: slot.linearIssue,
926
+ label: slot.label,
927
+ workloadType: slot.workloadType,
928
+ vmId: slot.vmId,
929
+ vmName: slot.vmName,
930
+ reason
931
+ });
932
+ reclaimedMemoryMb += slot.memoryMb;
933
+ // Process attached VM cleanup if present
934
+ if (slot.vmId !== null && slot.vmId !== undefined) {
935
+ const vmTarget = {
936
+ vmid: slot.vmId,
937
+ name: slot.vmName || undefined,
938
+ isProtected: false
939
+ };
940
+ // Guardrail safety check
941
+ const protCheck = this.guardrailEngine.isProtected(vmTarget);
942
+ if (protCheck.protected) {
943
+ skippedProtectedCount++;
944
+ destroyedVms.push({
945
+ vmId: slot.vmId,
946
+ vmName: slot.vmName || undefined,
947
+ hostId: slot.hostId,
948
+ hypervisorType: slot.hypervisorType,
949
+ status: "skipped_protected",
950
+ reason: `Protected infrastructure preserved: ${protCheck.reason}`
951
+ });
952
+ this.telemetry.emit({
953
+ event: "vm.guardrail_blocked",
954
+ actor: slot.requester || "system",
955
+ status: "blocked",
956
+ linearIssue: slot.linearIssue || undefined,
957
+ vmId: slot.vmId,
958
+ vmName: slot.vmName || undefined,
959
+ hostId: slot.hostId,
960
+ hypervisorType: slot.hypervisorType,
961
+ error: `Guardrail blocked purge destruction: ${protCheck.reason}`
962
+ });
963
+ }
964
+ else {
965
+ if (dryRun) {
966
+ destroyedVms.push({
967
+ vmId: slot.vmId,
968
+ vmName: slot.vmName || undefined,
969
+ hostId: slot.hostId,
970
+ hypervisorType: slot.hypervisorType,
971
+ status: "dry_run",
972
+ reason: `Would be destroyed during purge (${reason})`
973
+ });
974
+ }
975
+ else if (destroyVms && this.hypervisorManager) {
976
+ try {
977
+ await this.hypervisorManager.vmAction({
978
+ vmIdOrName: String(slot.vmId),
979
+ targetHost: slot.hostId,
980
+ action: "destroy",
981
+ force: true
982
+ });
983
+ destroyedVms.push({
984
+ vmId: slot.vmId,
985
+ vmName: slot.vmName || undefined,
986
+ hostId: slot.hostId,
987
+ hypervisorType: slot.hypervisorType,
988
+ status: "destroyed",
989
+ reason: `Purged and destroyed attached VM (${reason})`
990
+ });
991
+ this.telemetry.emit({
992
+ event: "vm.purged",
993
+ actor: slot.requester || "system",
994
+ status: "success",
995
+ linearIssue: slot.linearIssue || undefined,
996
+ vmId: slot.vmId,
997
+ vmName: slot.vmName || undefined,
998
+ hostId: slot.hostId,
999
+ hypervisorType: slot.hypervisorType,
1000
+ metadata: { slotId: slot.slotId, reason }
1001
+ });
1002
+ }
1003
+ catch (err) {
1004
+ destroyedVms.push({
1005
+ vmId: slot.vmId,
1006
+ vmName: slot.vmName || undefined,
1007
+ hostId: slot.hostId,
1008
+ hypervisorType: slot.hypervisorType,
1009
+ status: "failed",
1010
+ error: err instanceof Error ? err.message : String(err)
1011
+ });
1012
+ }
1013
+ }
1014
+ }
1015
+ }
1016
+ // If not a dry run, reset slot state
1017
+ if (!dryRun) {
1018
+ this.telemetry.emit({
1019
+ event: "slot.purge",
1020
+ actor: slot.requester || "system",
1021
+ status: "success",
1022
+ linearIssue: slot.linearIssue || undefined,
1023
+ slotId: slot.slotId,
1024
+ claimId: slot.claimId || undefined,
1025
+ vmId: slot.vmId || undefined,
1026
+ vmName: slot.vmName || undefined,
1027
+ hostId: slot.hostId,
1028
+ hypervisorType: slot.hypervisorType,
1029
+ metadata: {
1030
+ reason,
1031
+ freedMemoryMb: slot.memoryMb
1032
+ }
1033
+ });
1034
+ slot.status = "AVAILABLE";
1035
+ slot.claimId = null;
1036
+ slot.claimedAt = null;
1037
+ slot.expiresAt = null;
1038
+ slot.durationMinutes = null;
1039
+ slot.ttlMinutes = null;
1040
+ slot.ttlSeconds = null;
1041
+ slot.tag = null;
1042
+ slot.label = null;
1043
+ slot.workloadType = null;
1044
+ slot.targetVersion = null;
1045
+ slot.branch = null;
1046
+ slot.customTags = [];
1047
+ slot.requester = null;
1048
+ slot.linearIssue = null;
1049
+ slot.sessionId = null;
1050
+ slot.purpose = null;
1051
+ slot.vmId = null;
1052
+ slot.vmName = null;
1053
+ slot.metadata = {};
1054
+ }
1055
+ }
1056
+ // Check orphan VMs if requested
1057
+ if (includeOrphans && this.hypervisorManager) {
1058
+ try {
1059
+ const allVms = await this.hypervisorManager.listUnifiedInventory({
1060
+ includeTemplates: false,
1061
+ host: targetHost,
1062
+ hypervisor: targetHypervisor
1063
+ });
1064
+ const activeVmIds = new Set(Array.from(this.slots.values())
1065
+ .map((s) => s.vmId)
1066
+ .filter((id) => id !== null && id !== undefined)
1067
+ .map((id) => String(id)));
1068
+ for (const vm of allVms) {
1069
+ // Check if VM is an untracked disposable VM without active lease
1070
+ if (!vm.isTemplate && !vm.isProtected && !activeVmIds.has(String(vm.id))) {
1071
+ const protCheck = this.guardrailEngine.isProtected({
1072
+ vmid: vm.id,
1073
+ name: vm.name,
1074
+ isProtected: vm.isProtected,
1075
+ tags: vm.tags
1076
+ });
1077
+ if (protCheck.protected) {
1078
+ skippedProtectedCount++;
1079
+ destroyedVms.push({
1080
+ vmId: vm.id,
1081
+ vmName: vm.name,
1082
+ hostId: vm.hostName,
1083
+ hypervisorType: vm.hypervisor,
1084
+ status: "skipped_protected",
1085
+ reason: `Protected orphan preserved: ${protCheck.reason}`
1086
+ });
1087
+ }
1088
+ else {
1089
+ if (dryRun) {
1090
+ destroyedVms.push({
1091
+ vmId: vm.id,
1092
+ vmName: vm.name,
1093
+ hostId: vm.hostName,
1094
+ hypervisorType: vm.hypervisor,
1095
+ status: "dry_run",
1096
+ reason: "Orphaned disposable VM with no active slot lease"
1097
+ });
1098
+ }
1099
+ else if (destroyVms) {
1100
+ try {
1101
+ await this.hypervisorManager.vmAction({
1102
+ vmIdOrName: String(vm.id),
1103
+ targetHost: vm.hostName,
1104
+ action: "destroy",
1105
+ force: true
1106
+ });
1107
+ destroyedVms.push({
1108
+ vmId: vm.id,
1109
+ vmName: vm.name,
1110
+ hostId: vm.hostName,
1111
+ hypervisorType: vm.hypervisor,
1112
+ status: "destroyed",
1113
+ reason: "Destroyed orphaned disposable VM with no active slot lease"
1114
+ });
1115
+ }
1116
+ catch (orphanErr) {
1117
+ destroyedVms.push({
1118
+ vmId: vm.id,
1119
+ vmName: vm.name,
1120
+ hostId: vm.hostName,
1121
+ hypervisorType: vm.hypervisor,
1122
+ status: "failed",
1123
+ error: orphanErr instanceof Error ? orphanErr.message : String(orphanErr)
1124
+ });
1125
+ }
1126
+ }
1127
+ }
1128
+ }
1129
+ }
1130
+ }
1131
+ catch (invErr) {
1132
+ console.error("[purgeSlots] Orphan inventory inspection error:", invErr);
1133
+ }
1134
+ }
1135
+ const destroyedCount = destroyedVms.filter((v) => v.status === "destroyed").length;
1136
+ const reclaimedMemoryBytes = reclaimedMemoryMb * 1024 * 1024;
1137
+ const prefix = dryRun ? "[DRY RUN] " : "";
1138
+ const message = `${prefix}Purged ${purgedSlots.length} slot lease(s), reclaimed ${reclaimedMemoryMb / 1024} GB RAM, processed ${destroyedVms.length} VM(s) (${destroyedCount} destroyed, ${skippedProtectedCount} protected skipped).`;
1139
+ return {
1140
+ purgedSlotsCount: purgedSlots.length,
1141
+ purgedSlots,
1142
+ destroyedVmsCount: destroyedCount,
1143
+ destroyedVms,
1144
+ skippedProtectedVmsCount: skippedProtectedCount,
1145
+ reclaimedMemoryBytes,
1146
+ reclaimedMemoryMb,
1147
+ dryRun,
1148
+ timestamp: new Date(now).toISOString(),
1149
+ message
1150
+ };
1151
+ });
1152
+ }
589
1153
  /**
590
1154
  * Attach a created VMID or VM name to a claimed slot.
591
1155
  */
@@ -636,6 +1200,11 @@ export class SlotManager {
636
1200
  slot.ttlMinutes = null;
637
1201
  slot.ttlSeconds = null;
638
1202
  slot.tag = null;
1203
+ slot.label = null;
1204
+ slot.workloadType = null;
1205
+ slot.targetVersion = null;
1206
+ slot.branch = null;
1207
+ slot.customTags = [];
639
1208
  slot.requester = null;
640
1209
  slot.linearIssue = null;
641
1210
  slot.sessionId = null;