rojo/plugin/src/ServeSession.lua
Joe 22e437a055 Implement fallback error handling and enhance scripts-only mode in ServeSession
- Added a fallback mechanism in the `__applyPatch` method to handle errors during patch application, logging warnings for any encountered issues.
- Improved the transitive closure logic in the `__connectScriptsOnlyWatchers` method to ensure all necessary temporary IDs are included as skeleton containers, enhancing the integrity of the synchronization process.

These changes aim to improve error resilience and ensure proper handling of script references in scripts-only mode.
2026-02-28 12:00:01 -06:00

1094 lines
35 KiB
Lua

local StudioService = game:GetService("StudioService")
local RunService = game:GetService("RunService")
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local SerializationService = game:GetService("SerializationService")
local Selection = game:GetService("Selection")
local HttpService = game:GetService("HttpService")
local Packages = script.Parent.Parent.Packages
local Log = require(Packages.Log)
local Fmt = require(Packages.Fmt)
local t = require(Packages.t)
local Promise = require(Packages.Promise)
local Timer = require(script.Parent.Timer)
local ChangeBatcher = require(script.Parent.ChangeBatcher)
local encodePatchUpdate = require(script.Parent.ChangeBatcher.encodePatchUpdate)
local encodeInstance = require(script.Parent.ChangeBatcher.encodeInstance)
local InstanceMap = require(script.Parent.InstanceMap)
local PatchSet = require(script.Parent.PatchSet)
local Matching = require(script.Parent.Reconciler.matching)
local Reconciler = require(script.Parent.Reconciler)
local strict = require(script.Parent.strict)
local Settings = require(script.Parent.Settings)
local Status = strict("Session.Status", {
NotStarted = "NotStarted",
Connecting = "Connecting",
Connected = "Connected",
Disconnected = "Disconnected",
})
local function debugPatch(object)
return Fmt.debugify(object, function(patch, output)
output:writeLine("Patch {{")
output:indent()
for removed in ipairs(patch.removed) do
output:writeLine("Remove ID {}", removed)
end
for id, added in pairs(patch.added) do
output:writeLine("Add ID {} {:#?}", id, added)
end
for _, updated in ipairs(patch.updated) do
output:writeLine("Update ID {} {:#?}", updated.id, updated)
end
output:unindent()
output:write("}")
end)
end
local function attemptReparent(instance, parent)
return pcall(function()
instance.Parent = parent
end)
end
local ServeSession = {}
ServeSession.__index = ServeSession
ServeSession.Status = Status
local validateServeOptions = t.strictInterface({
apiContext = t.table,
twoWaySync = t.boolean,
})
function ServeSession.new(options)
assert(validateServeOptions(options))
-- Declare self ahead of time to capture it in a closure
local self
local function onInstanceChanged(instance, propertyName)
if not self.__twoWaySync then
return
end
self.__changeBatcher:add(instance, propertyName)
end
local function onChangesFlushed(patch)
-- ONE-SHOT MODE: Block ALL automatic outgoing writes from the ChangeBatcher.
-- The only writes allowed in One-shot mode are explicit "pull" selections
-- from the confirmation dialog (handled directly in __confirmAndApplyInitialPatch).
-- This ensures nothing can be pushed to the server without explicit user action.
if Settings:get("oneShotSync") then
Log.info("One-shot mode: blocking automatic outgoing write from ChangeBatcher")
return
end
self.__apiContext:write(patch)
end
local instanceMap = InstanceMap.new(onInstanceChanged)
local changeBatcher = ChangeBatcher.new(instanceMap, onChangesFlushed)
local reconciler = Reconciler.new(instanceMap)
local connections = {}
local connection = StudioService:GetPropertyChangedSignal("ActiveScript"):Connect(function()
local activeScript = StudioService.ActiveScript
if activeScript ~= nil then
self:__onActiveScriptChanged(activeScript)
end
end)
table.insert(connections, connection)
self = {
__status = Status.NotStarted,
__apiContext = options.apiContext,
__twoWaySync = options.twoWaySync,
__syncSourceOnly = false,
__syncScriptsOnly = false,
__reconciler = reconciler,
__instanceMap = instanceMap,
__changeBatcher = changeBatcher,
__statusChangedCallback = nil,
__userConfirmCallback = nil,
__patchUpdateCallback = nil,
__initialSyncCompleteCallback = nil,
__serverInfo = nil,
__confirmingPatch = nil,
__isConfirming = false, -- Explicit confirmation state flag for defense-in-depth
__applyingPatch = false, -- Set during reconciler patch application to suppress DescendantAdded
__connections = connections,
__precommitCallbacks = {},
__postcommitCallbacks = {},
__updateLoadingText = function() end,
}
setmetatable(self, ServeSession)
return self
end
function ServeSession:__fmtDebug(output)
output:writeLine("ServeSession {{")
output:indent()
output:writeLine("API Context: {:#?}", self.__apiContext)
output:writeLine("Instances: {:#?}", self.__instanceMap)
output:unindent()
output:write("}")
end
function ServeSession:getStatus()
return self.__status
end
function ServeSession:onStatusChanged(callback)
self.__statusChangedCallback = callback
end
function ServeSession:setConfirmCallback(callback)
self.__userConfirmCallback = callback
end
function ServeSession:setPatchUpdateCallback(callback)
self.__patchUpdateCallback = callback
end
function ServeSession:setInitialSyncCompleteCallback(callback)
self.__initialSyncCompleteCallback = callback
end
function ServeSession:setUpdateLoadingTextCallback(callback)
self.__updateLoadingText = callback
end
function ServeSession:setLoadingText(text: string)
self.__updateLoadingText(text)
end
--[=[
Hooks a function to run before patch application.
The provided function is called with the incoming patch and an InstanceMap
as parameters.
]=]
function ServeSession:hookPrecommit(callback)
table.insert(self.__precommitCallbacks, callback)
Log.trace("Added precommit callback: {}", callback)
return function()
-- Remove the callback from the list
for i, cb in self.__precommitCallbacks do
if cb == callback then
table.remove(self.__precommitCallbacks, i)
Log.trace("Removed precommit callback: {}", callback)
break
end
end
end
end
--[=[
Hooks a function to run after patch application.
The provided function is called with the applied patch, the current
InstanceMap, and a PatchSet containing any unapplied changes.
]=]
function ServeSession:hookPostcommit(callback)
table.insert(self.__postcommitCallbacks, callback)
Log.trace("Added postcommit callback: {}", callback)
return function()
-- Remove the callback from the list
for i, cb in self.__postcommitCallbacks do
if cb == callback then
table.remove(self.__postcommitCallbacks, i)
Log.trace("Removed postcommit callback: {}", callback)
break
end
end
end
end
function ServeSession:__onWebSocketMessage(messagesPacket)
if self.__status == Status.Disconnected then
return
end
Log.info("Received {} messages from Rojo server", #messagesPacket.messages)
-- Combine all messages into a single patch
local combinedPatch = PatchSet.newEmpty()
for _, message in messagesPacket.messages do
PatchSet.assign(combinedPatch, message)
end
local addedCount = 0
for _ in pairs(combinedPatch.added) do
addedCount += 1
end
Log.info(
"Combined patch: {} removals, {} additions, {} updates",
#combinedPatch.removed,
addedCount,
#combinedPatch.updated
)
-- If we're already waiting for confirmation, merge into the patch being confirmed
-- Changes flow into the dialogue even in one-shot mode so user can see them
if self.__confirmingPatch ~= nil then
Log.info("Already confirming, merging into current patch")
-- Collect IDs of items that are being changed (for unselecting in UI)
local changedIds = {}
for _, update in ipairs(combinedPatch.updated) do
changedIds[update.id] = true
end
for _, removed in ipairs(combinedPatch.removed) do
local id = if type(removed) == "string" then removed else self.__instanceMap.fromInstances[removed]
if id then
changedIds[id] = true
end
end
for id in pairs(combinedPatch.added) do
changedIds[id] = true
end
PatchSet.merge(self.__confirmingPatch, combinedPatch, self.__instanceMap)
-- Notify the UI to update the displayed patch and unselect changed items
if self.__patchUpdateCallback ~= nil then
self.__patchUpdateCallback(self.__instanceMap, self.__confirmingPatch, changedIds)
end
self.__apiContext:setMessageCursor(messagesPacket.messageCursor)
return
end
-- Set confirmation state flags before spawning to prevent race condition
-- where another message arrives before the spawned thread starts
self.__isConfirming = true
self.__confirmingPatch = combinedPatch
-- Pause ChangeBatcher during confirmation to prevent outgoing changes
self.__changeBatcher:pause()
-- Spawn a new thread to handle potentially yielding confirmation
task.spawn(function()
Log.trace("Processing WebSocket patch, callback exists: {}", self.__userConfirmCallback ~= nil)
Log.trace("ServerInfo exists: {}", self.__serverInfo ~= nil)
local userDecision = "Accept"
if self.__userConfirmCallback ~= nil then
userDecision = self.__userConfirmCallback(self.__instanceMap, combinedPatch, self.__serverInfo)
end
-- Clear confirmation state flags
self.__confirmingPatch = nil
self.__isConfirming = false
-- If the session was disconnected during the confirmation yield
-- (e.g., WebSocket error, server crash), bail out immediately.
if self.__status == Status.Disconnected then
return
end
-- Resume ChangeBatcher after confirmation
self.__changeBatcher:resume()
Log.trace("WebSocket patch decision: {}", userDecision)
if userDecision == "Abort" then
self:__stopInternal("Aborted Atlas sync operation")
return
elseif userDecision == "Accept" then
-- combinedPatch may have been updated with additional changes
-- that arrived while we were waiting for confirmation
self:__applyPatch(combinedPatch)
end
-- Note: Table-type responses ({ type = "Confirm", selections = ... }) from
-- the selection-based UI are not yet handled here. Ongoing WebSocket patches
-- with per-item selections would need the same logic as
-- __confirmAndApplyInitialPatch to properly respect push/pull/ignore choices.
end)
self.__apiContext:setMessageCursor(messagesPacket.messageCursor)
end
function ServeSession:start()
self:__setStatus(Status.Connecting)
self:setLoadingText("Connecting to server...")
self.__apiContext
:connect()
:andThen(function(serverInfo)
self:setLoadingText("Loading initial data from server...")
-- Configure sync mode based on server capabilities
self.__syncSourceOnly = serverInfo.syncSourceOnly or false
self.__changeBatcher:setSyncSourceOnly(self.__syncSourceOnly)
self.__syncScriptsOnly = serverInfo.syncScriptsOnly or false
self.__changeBatcher:setSyncScriptsOnly(self.__syncScriptsOnly)
self.__serverInfo = serverInfo
return self:__computeInitialPatch(serverInfo):andThen(function(catchUpPatch)
self:setLoadingText("Starting sync loop...")
-- Connect WebSocket BEFORE showing confirmation so changes
-- during confirmation can be merged into the patch
-- Note: connectWebSocket returns a Promise that only resolves when
-- the connection closes, so we don't chain .andThen() on it
self.__apiContext
:connectWebSocket({
["messages"] = function(messagesPacket)
self:__onWebSocketMessage(messagesPacket)
end,
})
:catch(function(err)
if self.__status ~= Status.Disconnected then
self:__stopInternal(err)
end
end)
-- Now show confirmation and wait for user decision
return self:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
end)
end)
:andThen(function()
-- Initial sync is fully complete (including any writes to the server)
if self.__initialSyncCompleteCallback ~= nil then
self.__initialSyncCompleteCallback()
end
self:__connectScriptsOnlyWatchers()
end)
:catch(function(err)
if self.__status ~= Status.Disconnected then
self:__stopInternal(err)
end
end)
end
function ServeSession:stop()
self:__stopInternal()
end
function ServeSession:__applyGameAndPlaceId(serverInfo)
if serverInfo.gameId ~= nil then
game:SetUniverseId(serverInfo.gameId)
end
if serverInfo.placeId ~= nil then
game:SetPlaceId(serverInfo.placeId)
end
end
function ServeSession:__onActiveScriptChanged(activeScript)
if not Settings:get("openScriptsExternally") then
Log.trace("Not opening script {} because feature not enabled.", activeScript)
return
end
if self.__status ~= Status.Connected then
Log.trace("Not opening script {} because session is not connected.", activeScript)
return
end
local scriptId = self.__instanceMap.fromInstances[activeScript]
if scriptId == nil then
Log.trace("Not opening script {} because it is not known by Rojo.", activeScript)
return
end
Log.debug("Trying to open script {} externally...", activeScript)
-- Force-close the script inside Studio... with a small delay in the middle
-- to prevent Studio from crashing.
spawn(function()
local existingParent = activeScript.Parent
activeScript.Parent = nil
for _ = 1, 3 do
RunService.Heartbeat:Wait()
end
activeScript.Parent = existingParent
end)
-- Notify the Rojo server to open this script
self.__apiContext:open(scriptId)
end
function ServeSession:__replaceInstances(idList)
if #idList == 0 then
return true, PatchSet.newEmpty()
end
-- It would be annoying if selection went away, so we try to preserve it.
local selection = Selection:Get()
local selectionMap = {}
for i, instance in selection do
selectionMap[instance] = i
end
-- TODO: Should we do this in multiple requests so we can more granularly mark failures?
local modelSuccess, replacements = self.__apiContext
:serialize(idList)
:andThen(function(response)
Log.debug("Deserializing results from serialize endpoint")
local objects = SerializationService:DeserializeInstancesAsync(response.modelContents)
if not objects[1] then
return Promise.reject("Serialize endpoint did not deserialize into any Instances")
end
if #objects[1]:GetChildren() ~= #idList then
return Promise.reject("Serialize endpoint did not return the correct number of Instances")
end
local instanceMap = {}
for _, item in objects[1]:GetChildren() do
instanceMap[item.Name] = item.Value
end
return instanceMap
end)
:await()
local refSuccess, refPatch = self.__apiContext
:refPatch(idList)
:andThen(function(response)
return response.patch
end)
:await()
if not (modelSuccess and refSuccess) then
return false
end
for id, replacement in replacements do
local oldInstance = self.__instanceMap.fromIds[id]
if not oldInstance then
-- TODO: Why would this happen?
Log.warn("Instance {} not found in InstanceMap during sync replacement", id)
continue
end
self.__instanceMap:insert(id, replacement)
Log.trace("Swapping Instance {} out via api/models/ endpoint", id)
local oldParent = oldInstance.Parent
for _, child in oldInstance:GetChildren() do
-- Some children cannot be reparented, such as a TouchTransmitter
local reparentSuccess, reparentError = attemptReparent(child, replacement)
if not reparentSuccess then
Log.warn(
"Could not reparent child {} of instance {} during sync replacement: {}",
child.Name,
oldInstance.Name,
reparentError
)
end
end
-- ChangeHistoryService doesn't like it if an Instance has been
-- Destroyed. So, we have to accept the potential memory hit and
-- just set the parent to `nil`.
local deleteSuccess, deleteError = attemptReparent(oldInstance, nil)
local replaceSuccess, replaceError = attemptReparent(replacement, oldParent)
if not (deleteSuccess and replaceSuccess) then
Log.warn(
"Could not swap instances {} and {} during sync replacement: {}",
oldInstance.Name,
replacement.Name,
(deleteError or "") .. "\n" .. (replaceError or "")
)
-- We need to revert the failed swap to avoid losing the old instance and children.
for _, child in replacement:GetChildren() do
attemptReparent(child, oldInstance)
end
attemptReparent(oldInstance, oldParent)
-- Our replacement should never have existed in the first place, so we can just destroy it.
replacement:Destroy()
continue
end
if selectionMap[oldInstance] then
-- This is a bit funky, but it saves the order of Selection
-- which might matter for some use cases.
selection[selectionMap[oldInstance]] = replacement
end
end
local patchApplySuccess, unappliedPatch = pcall(self.__reconciler.applyPatch, self.__reconciler, refPatch)
if patchApplySuccess then
Selection:Set(selection)
return true, unappliedPatch
else
error(unappliedPatch)
end
end
function ServeSession:__applyPatch(patch)
-- Defense-in-depth: Prevent unexpected patch application during confirmation
-- The only valid case is applying the confirmingPatch itself after user approval
if self.__isConfirming and patch ~= self.__confirmingPatch then
Log.error("Attempted to apply unexpected patch while confirmation dialogue is open. This is a bug!")
return
end
local patchTimestamp = DateTime.now():FormatLocalTime("LTS", "en-us")
local historyRecording = ChangeHistoryService:TryBeginRecording("Atlas: Patch " .. patchTimestamp)
if not historyRecording then
-- There can only be one recording at a time
Log.debug("Failed to begin history recording for " .. patchTimestamp .. ". Another recording is in progress.")
end
Timer.start("precommitCallbacks")
-- Precommit callbacks must be serial in order to obey the contract that
-- they execute before commit
for _, callback in self.__precommitCallbacks do
local success, err = pcall(callback, patch, self.__instanceMap)
if not success then
Log.warn("Precommit hook errored: {}", err)
end
end
Timer.stop()
self.__applyingPatch = true
local patchApplySuccess, unappliedPatch = pcall(self.__reconciler.applyPatch, self.__reconciler, patch)
if not patchApplySuccess then
self.__applyingPatch = false
if historyRecording then
ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
end
-- This might make a weird stack trace but the only way applyPatch can
-- fail is if a bug occurs so it's probably fine.
error(unappliedPatch)
end
local fallbackOk, fallbackErr = pcall(function()
if Settings:get("enableSyncFallback") and not PatchSet.isEmpty(unappliedPatch) then
-- Some changes did not apply, let's try replacing them instead
local addedIdList = PatchSet.addedIdList(unappliedPatch)
local updatedIdList = PatchSet.updatedIdList(unappliedPatch)
Log.debug("ServeSession:__replaceInstances(unappliedPatch.added)")
Timer.start("ServeSession:__replaceInstances(unappliedPatch.added)")
local addSuccess, unappliedAddedRefs = self:__replaceInstances(addedIdList)
Timer.stop()
Log.debug("ServeSession:__replaceInstances(unappliedPatch.updated)")
Timer.start("ServeSession:__replaceInstances(unappliedPatch.updated)")
local updateSuccess, unappliedUpdateRefs = self:__replaceInstances(updatedIdList)
Timer.stop()
-- Update the unapplied patch to reflect which Instances were replaced successfully
if addSuccess then
table.clear(unappliedPatch.added)
PatchSet.assign(unappliedPatch, unappliedAddedRefs)
end
if updateSuccess then
table.clear(unappliedPatch.updated)
PatchSet.assign(unappliedPatch, unappliedUpdateRefs)
end
end
end)
self.__applyingPatch = false
if not fallbackOk then
Log.warn("Sync fallback errored: {}", fallbackErr)
end
if not PatchSet.isEmpty(unappliedPatch) then
Log.info(
"Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch)
)
end
Timer.start("postcommitCallbacks")
-- Postcommit callbacks can be called with spawn since regardless of firing order, they are
-- guaranteed to be called after the commit
for _, callback in self.__postcommitCallbacks do
task.spawn(function()
local success, err = pcall(callback, patch, self.__instanceMap, unappliedPatch)
if not success then
Log.warn("Postcommit hook errored: {}", err)
end
end)
end
Timer.stop()
if historyRecording then
ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
end
end
function ServeSession:__computeInitialPatch(serverInfo)
return self.__apiContext:read({ serverInfo.rootInstanceId }):andThen(function(readResponseBody)
-- Tell the API Context that we're up-to-date with the version of
-- the tree defined in this response.
self.__apiContext:setMessageCursor(readResponseBody.messageCursor)
-- For any instances that line up with the Rojo server's view, start
-- tracking them in the reconciler.
Log.trace("Matching existing Roblox instances to Rojo IDs")
self:setLoadingText("Hydrating instance map...")
local matchingSession = Matching.newSession()
self.__reconciler:hydrate(readResponseBody.instances, serverInfo.rootInstanceId, game, matchingSession)
-- Calculate the initial patch to apply to the DataModel to catch us
-- up to what Rojo thinks the place should look like.
Log.trace("Computing changes that plugin needs to make to catch up to server...")
self:setLoadingText("Finding differences between server and Studio...")
local success, catchUpPatch =
self.__reconciler:diff(readResponseBody.instances, serverInfo.rootInstanceId, serverInfo)
if not success then
Log.error("Could not compute a diff to catch up to the Rojo server: {:#?}", catchUpPatch)
end
for _, update in catchUpPatch.updated do
if update.id == self.__instanceMap.fromInstances[game] and update.changedClassName ~= nil then
-- Non-place projects will try to update the classname of game from DataModel to
-- something like Folder, ModuleScript, etc. This would fail, so we exit with a clear
-- message instead of crashing.
return Promise.reject(
"Cannot sync a model as a place."
.. "\nEnsure Rojo is serving a project file that has a DataModel at the root of its tree and try again."
.. "\nSee project file docs: https://rojo.space/docs/v7/project-format/"
)
end
end
Log.trace("Computed hydration patch: {:#?}", debugPatch(catchUpPatch))
return catchUpPatch
end)
end
function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
local userDecision = "Accept"
if self.__userConfirmCallback ~= nil then
-- Pause ChangeBatcher during confirmation to prevent change accumulation
self.__changeBatcher:pause()
-- Set confirmation state flags
self.__isConfirming = true
self.__confirmingPatch = catchUpPatch
userDecision = self.__userConfirmCallback(self.__instanceMap, catchUpPatch, serverInfo)
-- Clear confirmation state flags
self.__confirmingPatch = nil
self.__isConfirming = false
-- If the session was disconnected during the confirmation yield
-- (e.g., WebSocket error, server crash), bail out immediately.
-- Don't resume batcher or apply patches on a dead session.
if self.__status == Status.Disconnected then
return Promise.reject("Session disconnected during confirmation")
end
-- Resume ChangeBatcher after confirmation
self.__changeBatcher:resume()
end
if userDecision == "Abort" then
return Promise.reject("Aborted Rojo sync operation")
elseif userDecision == "Accept" then
-- Legacy: Accept all changes (push all to Studio)
self:__setStatus(Status.Connected, serverInfo.projectName)
self:__applyGameAndPlaceId(serverInfo)
self:__applyPatch(catchUpPatch)
return Promise.resolve()
elseif type(userDecision) == "table" and userDecision.type == "Confirm" then
-- New: Apply based on per-item selections
local selections = userDecision.selections or {}
local autoSelectedIds = userDecision.autoSelectedIds or {}
-- Log selection summary (debug level)
local pushCount, pullCount, ignoreCount = 0, 0, 0
for _, selection in pairs(selections) do
if selection == "push" then
pushCount += 1
elseif selection == "pull" then
pullCount += 1
else
ignoreCount += 1
end
end
Log.info("User selections: {} push, {} pull, {} ignored", pushCount, pullCount, ignoreCount)
-- Build partial patches based on selections
local pushPatch = PatchSet.newEmpty() -- Items to apply to Studio
local pullPatch = PatchSet.newEmpty() -- Items to send back to Rojo
-- Build stage_ids: all push-accepted IDs + auto-selected pull-accepted IDs
local stageIds = {}
-- Process updated items
for _, change in catchUpPatch.updated do
local selection = selections[change.id]
local instance = self.__instanceMap.fromIds[change.id]
local instancePath = if instance then instance:GetFullName() else "ID:" .. tostring(change.id)
if selection == "push" then
-- Apply Rojo changes to Studio
Log.info("[Push] Update: {}", instancePath)
table.insert(pushPatch.updated, change)
table.insert(stageIds, change.id)
elseif selection == "pull" and self.__twoWaySync then
-- Send Studio state back to Rojo
if instance then
local propertiesToSync = change.changedProperties
if self.__syncSourceOnly then
if propertiesToSync.Source then
propertiesToSync = { Source = propertiesToSync.Source }
else
continue
end
end
Log.info("[Pull] Update: {}", instancePath)
local update = encodePatchUpdate(
instance,
change.id,
propertiesToSync,
self.__instanceMap,
function(sourceInstance, propertyName, targetInstance)
-- Defer unresolved Refs for retry via ChangeBatcher.
-- After the pull patch is sent, the target may appear
-- in the InstanceMap via forward-sync reconciliation.
self.__changeBatcher:deferUnresolvedRef(sourceInstance, propertyName, targetInstance)
end
)
if update then
table.insert(pullPatch.updated, update)
if autoSelectedIds[change.id] then
table.insert(stageIds, change.id)
end
end
end
end
-- "ignore" items are skipped
end
-- Process removed items (instances in Studio that don't exist in Rojo)
for _, idOrInstance in catchUpPatch.removed do
-- For removed items, idOrInstance is the Studio Instance
-- Note: use typeof() for Roblox Instances, not type() which returns "userdata"
local instance = if typeof(idOrInstance) == "Instance"
then idOrInstance
else self.__instanceMap.fromIds[idOrInstance]
-- Try to get an ID for selection lookup
local id = if typeof(idOrInstance) == "string"
then idOrInstance
else self.__instanceMap.fromInstances[idOrInstance]
-- If we don't have an ID, use the instance itself as the key
local selection = if id then selections[id] else selections[idOrInstance]
local instancePath = if instance then instance:GetFullName() else "ID:" .. tostring(idOrInstance)
local instanceClass = if instance then instance.ClassName else "unknown"
if selection == "push" then
-- Apply Rojo removal to Studio (delete the instance)
Log.info("[Push] Delete: {}", instancePath)
table.insert(pushPatch.removed, idOrInstance)
if id then
table.insert(stageIds, id)
end
elseif selection == "pull" and self.__twoWaySync then
-- Syncback: Create file in Rojo from Studio instance
if instance and instance.Parent then
local parentId = self.__instanceMap.fromInstances[instance.Parent]
if parentId then
local encoded = encodeInstance(instance, parentId)
if encoded then
-- Generate a temporary ref for the new instance
-- Use HttpService:GenerateGUID() and strip dashes to get 32-char hex
local guid = HttpService:GenerateGUID(false)
local tempRef = string.gsub(guid, "-", ""):lower()
pullPatch.added[tempRef] = encoded
if id and autoSelectedIds[id] then
table.insert(stageIds, tempRef)
end
-- Note: We intentionally do NOT pre-insert into InstanceMap here.
-- The VFS watcher will process the written files and assign a
-- server-side Ref ID. Any Ref properties targeting this instance
-- will be deferred by the ChangeBatcher and resolved once the
-- server ID appears in the InstanceMap via forward-sync.
-- Log at info level since this is a major file creation operation
Log.info("[Pull] Create file: {} ({})", instancePath, instanceClass)
end
else
Log.warn("Cannot syncback {:?}: parent not in Rojo tree", instance)
end
end
end
-- "skip"/"ignore" items are skipped
end
-- Process added items
for id, change in catchUpPatch.added do
local selection = selections[id]
local instanceName = change.Name or "unknown"
local instanceClass = change.ClassName or "unknown"
local parentInstance = self.__instanceMap.fromIds[change.Parent]
local parentPath = if parentInstance then parentInstance:GetFullName() else "ID:" .. tostring(change.Parent)
if selection == "push" then
-- Apply Rojo addition to Studio
Log.info("[Push] Add: {}.{}", parentPath, instanceName)
pushPatch.added[id] = change
table.insert(stageIds, id)
elseif selection == "pull" and self.__twoWaySync then
-- Don't add in Studio, remove from Rojo
-- Log at info level since this is a major file deletion operation
Log.info("[Pull] Delete file: {}.{} ({})", parentPath, instanceName, instanceClass)
table.insert(pullPatch.removed, id)
end
-- "ignore" items are skipped
end
-- Apply the changes
self:__setStatus(Status.Connected, serverInfo.projectName)
self:__applyGameAndPlaceId(serverInfo)
-- Apply push items to Studio
if not PatchSet.isEmpty(pushPatch) then
local addCount = 0
for _ in pairs(pushPatch.added) do
addCount += 1
end
Log.info(
"Applying to Studio: {} additions, {} removals, {} updates",
addCount,
#pushPatch.removed,
#pushPatch.updated
)
self:__applyPatch(pushPatch)
end
-- Send pull items + stage requests to Rojo server
local hasPullChanges = self.__twoWaySync and not PatchSet.isEmpty(pullPatch)
local hasStageIds = #stageIds > 0
if hasPullChanges or hasStageIds then
if hasPullChanges then
local addCount = 0
for _ in pairs(pullPatch.added) do
addCount += 1
end
Log.info(
"Sending to Rojo: {} file creations, {} file deletions, {} updates",
addCount,
#pullPatch.removed,
#pullPatch.updated
)
end
if hasStageIds then
Log.info("Requesting git stage for {} files", #stageIds)
end
return self.__apiContext:write(pullPatch, stageIds)
end
return Promise.resolve()
else
return Promise.reject("Invalid user decision: " .. tostring(userDecision))
end
end
function ServeSession:__connectScriptsOnlyWatchers()
if not self.__syncScriptsOnly then
return
end
if not self.__twoWaySync then
return
end
local services = {}
if self.__serverInfo and self.__serverInfo.visibleServices then
for _, serviceName in ipairs(self.__serverInfo.visibleServices) do
local ok, service = pcall(game.GetService, game, serviceName)
if ok and service then
table.insert(services, service)
end
end
else
for _, instance in self.__instanceMap.fromIds do
if instance.Parent == game then
table.insert(services, instance)
end
end
end
for _, service in services do
local conn = service.DescendantAdded:Connect(function(descendant)
if not self.__twoWaySync then
return
end
if self.__status ~= Status.Connected then
return
end
if Settings:get("oneShotSync") then
return
end
if RunService:IsRunning() then
return
end
if self.__changeBatcher:isPaused() then
return
end
if self.__applyingPatch then
return
end
if not descendant:IsA("LuaSourceContainer") then
return
end
if self.__instanceMap.fromInstances[descendant] then
return
end
local chain = {}
local current = descendant.Parent
while current and current ~= game do
if self.__instanceMap.fromInstances[current] then
break
end
table.insert(chain, 1, current)
current = current.Parent
end
local mappedParent = current
local mappedParentId = mappedParent and self.__instanceMap.fromInstances[mappedParent]
if not mappedParentId then
local rootId = self.__instanceMap.fromInstances[game]
if not rootId then
return
end
table.insert(chain, 1, service)
mappedParentId = rootId
end
local patch = PatchSet.newEmpty()
local prevParentId = mappedParentId
for _, intermediate in chain do
local existingId = self.__instanceMap.fromInstances[intermediate]
if existingId then
prevParentId = existingId
continue
end
local tempId = string.gsub(HttpService:GenerateGUID(false), "-", ""):lower()
patch.added[tempId] = {
parent = prevParentId,
name = intermediate.Name,
className = intermediate.ClassName,
properties = {},
children = {},
}
self.__instanceMap:insert(tempId, intermediate)
self.__instanceMap.scriptsOnlyTempIds[tempId] = true
prevParentId = tempId
end
local scriptTempId = string.gsub(HttpService:GenerateGUID(false), "-", ""):lower()
local encoded = encodeInstance(descendant, prevParentId)
if not encoded then
Log.warn(
"Scripts-only: failed to encode new script {}, rolling back temp IDs",
descendant:GetFullName()
)
for tempId in patch.added do
self.__instanceMap:removeId(tempId)
self.__instanceMap.scriptsOnlyTempIds[tempId] = nil
end
return
end
patch.added[scriptTempId] = encoded
self.__instanceMap:insert(scriptTempId, descendant)
self.__instanceMap.scriptsOnlyTempIds[scriptTempId] = true
-- Transitive closure: ensure every temp-ID parent referenced by
-- a patch entry is itself included as a skeleton container. When
-- multiple scripts are created in the same pruned subtree (e.g.,
-- paste), later handlers find intermediates already in instanceMap
-- with temp IDs from earlier handlers. Without re-including those
-- skeletons, the server's Phase 0 can't resolve the temp parents.
local closureNeeded = true
while closureNeeded do
closureNeeded = false
local toAdd = {}
for _, entry in pairs(patch.added) do
local pid = entry.parent
if
pid
and self.__instanceMap.scriptsOnlyTempIds[pid]
and not patch.added[pid]
and not toAdd[pid]
then
local inst = self.__instanceMap.fromIds[pid]
if inst then
local pp = inst.Parent
local ppId = pp and self.__instanceMap.fromInstances[pp]
if ppId then
toAdd[pid] = {
parent = ppId,
name = inst.Name,
className = inst.ClassName,
properties = {},
children = {},
}
closureNeeded = true
end
end
end
end
for id, entry in toAdd do
patch.added[id] = entry
end
end
Log.info(
"Scripts-only: detected new script {} outside mapped tree, sending to server",
descendant:GetFullName()
)
self.__apiContext:write(patch)
end)
table.insert(self.__connections, conn)
end
end
function ServeSession:__stopInternal(err)
self:__setStatus(Status.Disconnected, err)
self.__apiContext:disconnect()
table.clear(self.__instanceMap.scriptsOnlyTempIds)
self.__instanceMap:stop()
self.__changeBatcher:stop()
for _, connection in ipairs(self.__connections) do
connection:Disconnect()
end
self.__connections = {}
end
function ServeSession:__setStatus(status, detail)
self.__status = status
if self.__statusChangedCallback ~= nil then
self.__statusChangedCallback(status, detail)
end
end
return ServeSession