dominus-cli 2.2.1 → 2.3.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.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "dominus-cli",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Dominus 2: a secure, MCP-native Roblox Studio engineering agent bridge.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "dominus-mcp": "./dist/mcp.js",
8
- "dominus-install-plugin": "./dist/install-plugin.js"
7
+ "dominus-mcp": "dist/mcp.js",
8
+ "dominus-install-plugin": "dist/install-plugin.js"
9
9
  },
10
- "main": "./dist/mcp.js",
10
+ "main": "dist/mcp.js",
11
11
  "scripts": {
12
12
  "dev": "tsx src/mcp/index.ts",
13
13
  "dev:mcp": "tsx src/mcp/index.ts",
@@ -76,6 +76,6 @@
76
76
  "license": "MIT",
77
77
  "repository": {
78
78
  "type": "git",
79
- "url": "https://github.com/eli/dominus"
79
+ "url": "git+https://github.com/eli/dominus.git"
80
80
  }
81
81
  }
@@ -256,9 +256,443 @@ return "__DOMINUS_BRIDGE_TOKEN__"
256
256
  </Properties>
257
257
  </Item>
258
258
  <Item class="ModuleScript" referent="2">
259
+ <Properties>
260
+ <string name="Name">Building</string>
261
+ <string name="Source"><![CDATA[local ChangeHistoryService = game:GetService("ChangeHistoryService")
262
+ local Selection = game:GetService("Selection")
263
+
264
+ local InstanceRegistry = require(script.Parent.InstanceRegistry)
265
+ local ValueCodec = require(script.Parent.ValueCodec)
266
+
267
+ local Building = {}
268
+
269
+ local MAX_ITEMS = 200
270
+ local MAX_PROPERTIES = 100
271
+
272
+ local ALLOWED_PART_CLASSES = {
273
+ Part = true,
274
+ WedgePart = true,
275
+ CornerWedgePart = true,
276
+ TrussPart = true,
277
+ MeshPart = true,
278
+ SpawnLocation = true,
279
+ Seat = true,
280
+ VehicleSeat = true,
281
+ }
282
+
283
+ local function resolve(target, label)
284
+ local instance, err = InstanceRegistry.resolve(target)
285
+ if not instance then
286
+ error(label .. ": " .. tostring(err))
287
+ end
288
+ return instance
289
+ end
290
+
291
+ local function cleanup(instances)
292
+ for _, instance in instances do
293
+ pcall(function()
294
+ if instance.Parent == nil then
295
+ instance:Destroy()
296
+ end
297
+ end)
298
+ end
299
+ end
300
+
301
+ local function record(name, callback, created)
302
+ local recording = ChangeHistoryService:TryBeginRecording(name)
303
+ if not recording then
304
+ return { success = false, error = "Another plugin recording is already active" }
305
+ end
306
+
307
+ local ok, result = pcall(callback)
308
+ if not ok then
309
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
310
+ cleanup(created or {})
311
+ return { success = false, error = tostring(result), rolledBack = true }
312
+ end
313
+
314
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
315
+ result.success = true
316
+ return result
317
+ end
318
+
319
+ local function finiteNumber(value, label)
320
+ assert(type(value) == "number" and value == value and math.abs(value) < math.huge, label .. " must be finite")
321
+ return value
322
+ end
323
+
324
+ local function vector3(value, label, default)
325
+ if value == nil then
326
+ return default
327
+ end
328
+ assert(type(value) == "table", label .. " must be a Vector3 object")
329
+ return Vector3.new(
330
+ finiteNumber(value.X or value.x or value[1], label .. ".x"),
331
+ finiteNumber(value.Y or value.y or value[2], label .. ".y"),
332
+ finiteNumber(value.Z or value.z or value[3], label .. ".z")
333
+ )
334
+ end
335
+
336
+ local function rotation(value, label)
337
+ local degrees = vector3(value, label, Vector3.zero)
338
+ return CFrame.fromOrientation(math.rad(degrees.X), math.rad(degrees.Y), math.rad(degrees.Z))
339
+ end
340
+
341
+ local function setProperties(instance, properties)
342
+ if properties == nil then
343
+ return {}
344
+ end
345
+ assert(type(properties) == "table", "properties must be an object")
346
+ local count = 0
347
+ local applied = {}
348
+ for propertyName, value in properties do
349
+ count += 1
350
+ if count > MAX_PROPERTIES then
351
+ error("At most " .. MAX_PROPERTIES .. " properties are allowed per item")
352
+ end
353
+ applied[propertyName] = ValueCodec.setProperty(instance, propertyName, value)
354
+ end
355
+ return applied
356
+ end
357
+
358
+ local function validateItemCount(items, label, maximum)
359
+ assert(type(items) == "table" and #items > 0, label .. " must contain at least one item")
360
+ assert(#items <= maximum, label .. " is limited to " .. maximum .. " items")
361
+ end
362
+
363
+ local function assertMovable(instance, label)
364
+ assert(instance ~= game and instance.Parent ~= game, label .. " cannot be a DataModel service")
365
+ end
366
+
367
+ function Building.createParts(spec)
368
+ validateItemCount(spec.parts, "parts", MAX_ITEMS)
369
+ local parent = spec.parent and resolve(spec.parent, "parent") or workspace
370
+ local group = nil
371
+ local created = {}
372
+
373
+ return record("Dominus 2: Build parts", function()
374
+ if spec.groupName ~= nil then
375
+ assert(type(spec.groupName) == "string" and #spec.groupName > 0, "groupName must be a non-empty string")
376
+ local groupClass = spec.groupClass or "Model"
377
+ assert(groupClass == "Model" or groupClass == "Folder", "groupClass must be Model or Folder")
378
+ group = Instance.new(groupClass)
379
+ group.Name = spec.groupName
380
+ table.insert(created, group)
381
+ end
382
+
383
+ local parts = {}
384
+ for index, item in spec.parts do
385
+ assert(type(item) == "table", "Part " .. index .. " must be an object")
386
+ assert(type(item.name) == "string" and #item.name > 0, "Part " .. index .. ".name must be a non-empty string")
387
+ local className = item.className or "Part"
388
+ if item.shape == "Wedge" then
389
+ className = "WedgePart"
390
+ elseif item.shape == "CornerWedge" then
391
+ className = "CornerWedgePart"
392
+ end
393
+ assert(ALLOWED_PART_CLASSES[className] == true, "Part " .. index .. " uses an unsupported class: " .. tostring(className))
394
+
395
+ local part = Instance.new(className)
396
+ table.insert(created, part)
397
+ part.Name = item.name
398
+ if part:IsA("BasePart") then
399
+ part.Anchored = item.anchored ~= false
400
+ end
401
+ if item.position then
402
+ part.Position = vector3(item.position, "Part " .. index .. ".position")
403
+ end
404
+ if item.orientation then
405
+ part.Orientation = vector3(item.orientation, "Part " .. index .. ".orientation")
406
+ end
407
+ if item.size then
408
+ part.Size = vector3(item.size, "Part " .. index .. ".size")
409
+ end
410
+ if item.color then
411
+ ValueCodec.setProperty(part, "Color", item.color)
412
+ end
413
+ if item.material then
414
+ ValueCodec.setProperty(part, "Material", item.material)
415
+ end
416
+ if item.transparency ~= nil then
417
+ part.Transparency = finiteNumber(item.transparency, "Part " .. index .. ".transparency")
418
+ end
419
+ if item.canCollide ~= nil then
420
+ assert(type(item.canCollide) == "boolean", "Part " .. index .. ".canCollide must be a boolean")
421
+ part.CanCollide = item.canCollide
422
+ end
423
+ if item.shape and className == "Part" then
424
+ ValueCodec.setProperty(part, "Shape", item.shape)
425
+ end
426
+ setProperties(part, item.properties)
427
+ part.Parent = group or parent
428
+ table.insert(parts, part)
429
+ end
430
+
431
+ if group then
432
+ group.Parent = parent
433
+ end
434
+
435
+ local refs = {}
436
+ for _, part in parts do
437
+ table.insert(refs, InstanceRegistry.toRef(part))
438
+ end
439
+ return {
440
+ created = refs,
441
+ group = group and InstanceRegistry.toRef(group) or nil,
442
+ count = #refs,
443
+ }
444
+ end, created)
445
+ end
446
+
447
+ function Building.cloneInstances(spec)
448
+ validateItemCount(spec.requests, "requests", 100)
449
+ local created = {}
450
+
451
+ return record("Dominus 2: Clone instances", function()
452
+ local clones = {}
453
+ local totalCopies = 0
454
+ for requestIndex, request in spec.requests do
455
+ assert(type(request) == "table", "Clone request " .. requestIndex .. " must be an object")
456
+ local source = resolve(request.target, "Clone request " .. requestIndex .. " target")
457
+ assertMovable(source, "Clone source")
458
+ local parent = request.parent and resolve(request.parent, "Clone request " .. requestIndex .. " parent") or source.Parent
459
+ assert(parent ~= nil, "Clone request " .. requestIndex .. " source has no parent")
460
+ local count = request.count or 1
461
+ assert(type(count) == "number" and count % 1 == 0 and count >= 1 and count <= 50, "count must be an integer from 1 to 50")
462
+ totalCopies += count
463
+ assert(totalCopies <= MAX_ITEMS, "A clone batch is limited to " .. MAX_ITEMS .. " copies")
464
+ local offset = vector3(request.offset, "Clone request " .. requestIndex .. ".offset", Vector3.zero)
465
+ local stepOffset = vector3(request.stepOffset, "Clone request " .. requestIndex .. ".stepOffset", Vector3.zero)
466
+
467
+ for copyIndex = 1, count do
468
+ local clone = source:Clone()
469
+ assert(clone ~= nil, "Clone request " .. requestIndex .. " target is not Archivable")
470
+ table.insert(created, clone)
471
+ if request.name then
472
+ clone.Name = count == 1 and request.name or (request.name .. " " .. copyIndex)
473
+ end
474
+ setProperties(clone, request.properties)
475
+ if clone:IsA("PVInstance") then
476
+ local totalOffset = offset + stepOffset * (copyIndex - 1)
477
+ clone:PivotTo(clone:GetPivot() + totalOffset)
478
+ elseif offset.Magnitude > 0 or stepOffset.Magnitude > 0 then
479
+ error("Clone request " .. requestIndex .. " cannot offset a non-PVInstance")
480
+ end
481
+ clone.Parent = parent
482
+ table.insert(clones, InstanceRegistry.toRef(clone))
483
+ end
484
+ end
485
+ return { clones = clones, count = #clones }
486
+ end, created)
487
+ end
488
+
489
+ local function validateIndependentTargets(targets)
490
+ for leftIndex, left in targets do
491
+ for rightIndex = leftIndex + 1, #targets do
492
+ local right = targets[rightIndex]
493
+ assert(not left:IsAncestorOf(right) and not right:IsAncestorOf(left), "Targets " .. leftIndex .. " and " .. rightIndex .. " overlap")
494
+ end
495
+ end
496
+ end
497
+
498
+ function Building.groupInstances(spec)
499
+ validateItemCount(spec.targets, "targets", 100)
500
+ assert(type(spec.name) == "string" and #spec.name > 0, "name must be a non-empty string")
501
+ local className = spec.className or "Model"
502
+ assert(className == "Model" or className == "Folder", "className must be Model or Folder")
503
+ local targets = {}
504
+ for index, targetRef in spec.targets do
505
+ local target = resolve(targetRef, "Target " .. index)
506
+ assertMovable(target, "Target " .. index)
507
+ table.insert(targets, target)
508
+ end
509
+ validateIndependentTargets(targets)
510
+ local parent = spec.parent and resolve(spec.parent, "parent") or targets[1].Parent
511
+ assert(parent ~= nil, "The first target has no parent")
512
+ local created = {}
513
+
514
+ return record("Dominus 2: Group instances", function()
515
+ local group = Instance.new(className)
516
+ table.insert(created, group)
517
+ group.Name = spec.name
518
+ group.Parent = parent
519
+ for _, target in targets do
520
+ assert(not target:IsAncestorOf(group), "Cannot group an ancestor into its descendant")
521
+ target.Parent = group
522
+ end
523
+ return { group = InstanceRegistry.toRef(group), count = #targets }
524
+ end, created)
525
+ end
526
+
527
+ function Building.ungroupInstances(spec)
528
+ assert(spec.confirm == true, "confirm must be true")
529
+ validateItemCount(spec.targets, "targets", 20)
530
+
531
+ return record("Dominus 2: Ungroup instances", function()
532
+ local moved = {}
533
+ local removed = {}
534
+ for index, targetRef in spec.targets do
535
+ local group = resolve(targetRef, "Group " .. index)
536
+ assert(group:IsA("Model") or group:IsA("Folder"), "Group " .. index .. " must be a Model or Folder")
537
+ assertMovable(group, "Group " .. index)
538
+ local parent = spec.parent and resolve(spec.parent, "parent") or group.Parent
539
+ assert(parent ~= nil, "Group " .. index .. " has no parent")
540
+ for _, child in group:GetChildren() do
541
+ child.Parent = parent
542
+ table.insert(moved, InstanceRegistry.toRef(child))
543
+ end
544
+ table.insert(removed, { name = group.Name, className = group.ClassName })
545
+ group:Destroy()
546
+ end
547
+ return { moved = moved, removedGroups = removed, count = #moved }
548
+ end)
549
+ end
550
+
551
+ local function absolutePivot(current, operation)
552
+ if operation.cframe then
553
+ return ValueCodec.decodeForCurrent(current, operation.cframe)
554
+ end
555
+ local position = vector3(operation.position, "position", current.Position)
556
+ local x, y, z = current:ToOrientation()
557
+ if operation.orientation then
558
+ local degrees = vector3(operation.orientation, "orientation")
559
+ x, y, z = math.rad(degrees.X), math.rad(degrees.Y), math.rad(degrees.Z)
560
+ end
561
+ return CFrame.new(position) * CFrame.fromOrientation(x, y, z)
562
+ end
563
+
564
+ function Building.transformInstances(spec)
565
+ validateItemCount(spec.operations, "operations", 100)
566
+
567
+ return record("Dominus 2: Transform instances", function()
568
+ local results = {}
569
+ for index, operation in spec.operations do
570
+ local target = resolve(operation.target, "Operation " .. index .. " target")
571
+ assert(target:IsA("PVInstance"), "Operation " .. index .. " target must be a Model or BasePart")
572
+ local current = target:GetPivot()
573
+ local mode = operation.mode or "absolute"
574
+ local nextPivot
575
+ if mode == "absolute" then
576
+ nextPivot = absolutePivot(current, operation)
577
+ elseif mode == "relative" then
578
+ local offset = vector3(operation.offset, "Operation " .. index .. ".offset", Vector3.zero)
579
+ local rotate = rotation(operation.rotationOffset, "Operation " .. index .. ".rotationOffset")
580
+ if operation.space == "local" then
581
+ nextPivot = current * CFrame.new(offset) * rotate
582
+ else
583
+ nextPivot = CFrame.new(offset) * current * rotate
584
+ end
585
+ else
586
+ error("Operation " .. index .. ".mode must be absolute or relative")
587
+ end
588
+ target:PivotTo(nextPivot)
589
+ table.insert(results, { target = InstanceRegistry.toRef(target), pivot = ValueCodec.encode(target:GetPivot()) })
590
+ end
591
+ return { transformed = results, count = #results }
592
+ end)
593
+ end
594
+
595
+ function Building.createWelds(spec)
596
+ validateItemCount(spec.welds, "welds", MAX_ITEMS)
597
+ local created = {}
598
+
599
+ return record("Dominus 2: Create welds", function()
600
+ local welds = {}
601
+ for index, item in spec.welds do
602
+ local part0 = resolve(item.part0, "Weld " .. index .. " part0")
603
+ local part1 = resolve(item.part1, "Weld " .. index .. " part1")
604
+ assert(part0:IsA("BasePart") and part1:IsA("BasePart"), "Weld " .. index .. " endpoints must be BaseParts")
605
+ assert(part0 ~= part1, "Weld " .. index .. " endpoints must be different")
606
+ local parent = item.parent and resolve(item.parent, "Weld " .. index .. " parent") or part0
607
+ local weld = Instance.new("WeldConstraint")
608
+ table.insert(created, weld)
609
+ weld.Name = item.name or (part0.Name .. "_to_" .. part1.Name)
610
+ weld.Part0 = part0
611
+ weld.Part1 = part1
612
+ weld.Parent = parent
613
+ table.insert(welds, InstanceRegistry.toRef(weld))
614
+ end
615
+ return { welds = welds, count = #welds }
616
+ end, created)
617
+ end
618
+
619
+ local function overlapParams(spec)
620
+ local params = OverlapParams.new()
621
+ local maxParts = spec.maxParts or 200
622
+ assert(type(maxParts) == "number" and maxParts % 1 == 0 and maxParts >= 1 and maxParts <= 500, "maxParts must be an integer from 1 to 500")
623
+ params.MaxParts = maxParts
624
+ params.RespectCanCollide = spec.respectCanCollide == true
625
+ if spec.filter and #spec.filter > 0 then
626
+ local instances = {}
627
+ for index, targetRef in spec.filter do
628
+ table.insert(instances, resolve(targetRef, "Filter " .. index))
629
+ end
630
+ params.FilterDescendantsInstances = instances
631
+ params.FilterType = spec.filterType == "Include" and Enum.RaycastFilterType.Include or Enum.RaycastFilterType.Exclude
632
+ end
633
+ return params
634
+ end
635
+
636
+ function Building.queryParts(spec)
637
+ local root = spec.root and resolve(spec.root, "root") or workspace
638
+ assert(root:IsA("WorldRoot"), "root must be Workspace or a WorldModel")
639
+ local mode = spec.mode or "box"
640
+ local found
641
+ if mode == "box" then
642
+ local position = vector3(spec.position, "position")
643
+ local size = vector3(spec.size, "size")
644
+ assert(size.X > 0 and size.Y > 0 and size.Z > 0, "size components must be greater than zero")
645
+ local box = CFrame.new(position) * rotation(spec.orientation, "orientation")
646
+ found = root:GetPartBoundsInBox(box, size, overlapParams(spec))
647
+ elseif mode == "radius" then
648
+ local position = vector3(spec.position, "position")
649
+ local radius = finiteNumber(spec.radius, "radius")
650
+ assert(radius > 0, "radius must be greater than zero")
651
+ found = root:GetPartBoundsInRadius(position, radius, overlapParams(spec))
652
+ else
653
+ error("mode must be box or radius")
654
+ end
655
+
656
+ table.sort(found, function(left, right)
657
+ return InstanceRegistry.getDisplayPath(left) < InstanceRegistry.getDisplayPath(right)
658
+ end)
659
+ local parts = {}
660
+ for _, part in found do
661
+ table.insert(parts, {
662
+ name = part.Name,
663
+ className = part.ClassName,
664
+ ref = InstanceRegistry.toRef(part),
665
+ position = ValueCodec.encode(part.Position),
666
+ size = ValueCodec.encode(part.Size),
667
+ })
668
+ end
669
+ return { success = true, parts = parts, count = #parts, mode = mode }
670
+ end
671
+
672
+ function Building.setSelection(spec)
673
+ assert(type(spec.targets) == "table", "targets must be an array")
674
+ assert(#spec.targets <= 200, "At most 200 instances can be selected")
675
+ local targets = {}
676
+ for index, targetRef in spec.targets do
677
+ table.insert(targets, resolve(targetRef, "Target " .. index))
678
+ end
679
+ Selection:Set(targets)
680
+ local selected = {}
681
+ for _, target in targets do
682
+ table.insert(selected, InstanceRegistry.toRef(target))
683
+ end
684
+ return { success = true, selection = selected, count = #selected }
685
+ end
686
+
687
+ return Building
688
+ ]]></string>
689
+ </Properties>
690
+ </Item>
691
+ <Item class="ModuleScript" referent="3">
259
692
  <Properties>
260
693
  <string name="Name">CommandRouter</string>
261
- <string name="Source"><![CDATA[local Explorer = require(script.Parent.Explorer)
694
+ <string name="Source"><![CDATA[local Building = require(script.Parent.Building)
695
+ local Explorer = require(script.Parent.Explorer)
262
696
  local InstanceRegistry = require(script.Parent.InstanceRegistry)
263
697
  local Mutation = require(script.Parent.Mutation)
264
698
  local Properties = require(script.Parent.Properties)
@@ -297,6 +731,30 @@ local handlers = {
297
731
  ["studio:v2:apply"] = function(payload)
298
732
  return Mutation.apply(payload)
299
733
  end,
734
+ ["studio:v2:create_parts"] = function(payload)
735
+ return Building.createParts(payload)
736
+ end,
737
+ ["studio:v2:clone_instances"] = function(payload)
738
+ return Building.cloneInstances(payload)
739
+ end,
740
+ ["studio:v2:group_instances"] = function(payload)
741
+ return Building.groupInstances(payload)
742
+ end,
743
+ ["studio:v2:ungroup_instances"] = function(payload)
744
+ return Building.ungroupInstances(payload)
745
+ end,
746
+ ["studio:v2:transform_instances"] = function(payload)
747
+ return Building.transformInstances(payload)
748
+ end,
749
+ ["studio:v2:create_welds"] = function(payload)
750
+ return Building.createWelds(payload)
751
+ end,
752
+ ["studio:v2:query_parts"] = function(payload)
753
+ return Building.queryParts(payload)
754
+ end,
755
+ ["studio:v2:set_selection"] = function(payload)
756
+ return Building.setSelection(payload)
757
+ end,
300
758
  ["studio:v2:delete"] = function(payload)
301
759
  return Mutation.delete(payload)
302
760
  end,
@@ -355,7 +813,7 @@ return CommandRouter
355
813
  ]]></string>
356
814
  </Properties>
357
815
  </Item>
358
- <Item class="ModuleScript" referent="3">
816
+ <Item class="ModuleScript" referent="4">
359
817
  <Properties>
360
818
  <string name="Name">Explorer</string>
361
819
  <string name="Source"><![CDATA[--[[
@@ -891,7 +1349,7 @@ return Explorer
891
1349
  ]]></string>
892
1350
  </Properties>
893
1351
  </Item>
894
- <Item class="ModuleScript" referent="4">
1352
+ <Item class="ModuleScript" referent="5">
895
1353
  <Properties>
896
1354
  <string name="Name">InstanceRegistry</string>
897
1355
  <string name="Source"><![CDATA[local HttpService = game:GetService("HttpService")
@@ -1019,7 +1477,7 @@ return InstanceRegistry
1019
1477
  ]]></string>
1020
1478
  </Properties>
1021
1479
  </Item>
1022
- <Item class="ModuleScript" referent="5">
1480
+ <Item class="ModuleScript" referent="6">
1023
1481
  <Properties>
1024
1482
  <string name="Name">Mutation</string>
1025
1483
  <string name="Source"><![CDATA[local ChangeHistoryService = game:GetService("ChangeHistoryService")
@@ -1068,6 +1526,7 @@ function Mutation.apply(spec)
1068
1526
  end
1069
1527
 
1070
1528
  local results = {}
1529
+ local created = {}
1071
1530
  local ok, err = pcall(function()
1072
1531
  for index, operation in operations do
1073
1532
  assert(type(operation) == "table", "Operation " .. index .. " must be an object")
@@ -1083,12 +1542,34 @@ function Mutation.apply(spec)
1083
1542
  assert(type(operation.className) == "string", "create.className must be a string")
1084
1543
  assert(type(operation.name) == "string", "create.name must be a string")
1085
1544
  local instance = Instance.new(operation.className)
1545
+ table.insert(created, instance)
1086
1546
  instance.Name = operation.name
1087
1547
  if operation.properties then
1088
1548
  setProperties(instance, operation.properties)
1089
1549
  end
1090
1550
  instance.Parent = parent
1091
1551
  table.insert(results, { op = operation.op, created = InstanceRegistry.toRef(instance) })
1552
+ elseif operation.op == "clone" then
1553
+ local target = resolve(operation.target, "Operation " .. index .. " target")
1554
+ assert(target ~= game and target.Parent ~= game, "Services cannot be cloned")
1555
+ local parent = operation.parent and resolve(operation.parent, "Operation " .. index .. " parent") or target.Parent
1556
+ assert(parent ~= nil, "Clone target has no parent")
1557
+ local clone = target:Clone()
1558
+ assert(clone ~= nil, "Clone target is not Archivable")
1559
+ table.insert(created, clone)
1560
+ if operation.name then
1561
+ clone.Name = operation.name
1562
+ end
1563
+ if operation.properties then
1564
+ setProperties(clone, operation.properties)
1565
+ end
1566
+ if operation.offset then
1567
+ assert(clone:IsA("PVInstance"), "Only Models and BaseParts can be spatially offset")
1568
+ local offset = ValueCodec.decodeForCurrent(Vector3.zero, operation.offset)
1569
+ clone:PivotTo(clone:GetPivot() + offset)
1570
+ end
1571
+ clone.Parent = parent
1572
+ table.insert(results, { op = operation.op, created = InstanceRegistry.toRef(clone) })
1092
1573
  elseif operation.op == "move" then
1093
1574
  local target = resolve(operation.target, "Operation " .. index .. " target")
1094
1575
  local parent = resolve(operation.parent, "Operation " .. index .. " parent")
@@ -1103,6 +1584,13 @@ function Mutation.apply(spec)
1103
1584
 
1104
1585
  if not ok then
1105
1586
  ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
1587
+ for _, instance in created do
1588
+ pcall(function()
1589
+ if instance.Parent == nil then
1590
+ instance:Destroy()
1591
+ end
1592
+ end)
1593
+ end
1106
1594
  return { success = false, error = tostring(err), rolledBack = true }
1107
1595
  end
1108
1596
 
@@ -1160,7 +1648,7 @@ return Mutation
1160
1648
  ]]></string>
1161
1649
  </Properties>
1162
1650
  </Item>
1163
- <Item class="ModuleScript" referent="6">
1651
+ <Item class="ModuleScript" referent="7">
1164
1652
  <Properties>
1165
1653
  <string name="Name">Properties</string>
1166
1654
  <string name="Source"><![CDATA[--[[
@@ -2025,7 +2513,7 @@ return Properties
2025
2513
  ]]></string>
2026
2514
  </Properties>
2027
2515
  </Item>
2028
- <Item class="ModuleScript" referent="7">
2516
+ <Item class="ModuleScript" referent="8">
2029
2517
  <Properties>
2030
2518
  <string name="Name">Protocol</string>
2031
2519
  <string name="Source"><![CDATA[--[[
@@ -2061,7 +2549,7 @@ return Protocol
2061
2549
  ]]></string>
2062
2550
  </Properties>
2063
2551
  </Item>
2064
- <Item class="ModuleScript" referent="8">
2552
+ <Item class="ModuleScript" referent="9">
2065
2553
  <Properties>
2066
2554
  <string name="Name">Reflection</string>
2067
2555
  <string name="Source"><![CDATA[local ReflectionService = game:GetService("ReflectionService")
@@ -2236,7 +2724,7 @@ return Reflection
2236
2724
  ]]></string>
2237
2725
  </Properties>
2238
2726
  </Item>
2239
- <Item class="ModuleScript" referent="9">
2727
+ <Item class="ModuleScript" referent="10">
2240
2728
  <Properties>
2241
2729
  <string name="Name">TestRunner</string>
2242
2730
  <string name="Source"><![CDATA[local StudioTestService = game:GetService("StudioTestService")
@@ -2311,7 +2799,7 @@ return TestRunner
2311
2799
  ]]></string>
2312
2800
  </Properties>
2313
2801
  </Item>
2314
- <Item class="ModuleScript" referent="10">
2802
+ <Item class="ModuleScript" referent="11">
2315
2803
  <Properties>
2316
2804
  <string name="Name">UIBuilder</string>
2317
2805
  <string name="Source"><![CDATA[--[[
@@ -3174,7 +3662,7 @@ return UIBuilder
3174
3662
  ]]></string>
3175
3663
  </Properties>
3176
3664
  </Item>
3177
- <Item class="ModuleScript" referent="11">
3665
+ <Item class="ModuleScript" referent="12">
3178
3666
  <Properties>
3179
3667
  <string name="Name">ValueCodec</string>
3180
3668
  <string name="Source"><![CDATA[local InstanceRegistry = require(script.Parent.InstanceRegistry)
@@ -3411,7 +3899,7 @@ return ValueCodec
3411
3899
  ]]></string>
3412
3900
  </Properties>
3413
3901
  </Item>
3414
- <Item class="ModuleScript" referent="12">
3902
+ <Item class="ModuleScript" referent="13">
3415
3903
  <Properties>
3416
3904
  <string name="Name">Watcher</string>
3417
3905
  <string name="Source"><![CDATA[--[[
@@ -3502,7 +3990,7 @@ return Watcher
3502
3990
  ]]></string>
3503
3991
  </Properties>
3504
3992
  </Item>
3505
- <Item class="ModuleScript" referent="13">
3993
+ <Item class="ModuleScript" referent="14">
3506
3994
  <Properties>
3507
3995
  <string name="Name">WsClient</string>
3508
3996
  <string name="Source"><![CDATA[local HttpService = game:GetService("HttpService")