mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
- 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.
1094 lines
35 KiB
Lua
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
|