Implement one-shot mode for explicit confirmation in ServeSession

- Introduced one-shot mode to block automatic outgoing writes and require explicit user confirmation for all changes.
- Updated confirmation logic in App:startSession to ensure no auto-acceptance occurs during one-shot mode.
- Enhanced logging to provide clarity on actions taken during one-shot mode, improving user awareness and control over sync operations.
This commit is contained in:
Joe 2026-01-29 13:47:56 -06:00
parent d002c4e821
commit 78aab0eca4
2 changed files with 80 additions and 68 deletions

View file

@ -709,28 +709,19 @@ function App:startSession()
end) end)
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo) serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
-- Helper to schedule one-shot disconnect after auto-accept local isOneShotMode = Settings:get("oneShotSync")
local function scheduleOneShotDisconnect()
if Settings:get("oneShotSync") then
task.delay(0.5, function()
if self.serveSession then
Log.info("One-shot sync: disconnecting after sync")
self:endSession()
end
end)
end
end
-- ONE-SHOT MODE: Never auto-accept, always require explicit confirmation
-- This ensures nothing can "sneak through" without user review
if not isOneShotMode then
if PatchSet.isEmpty(patch) then if PatchSet.isEmpty(patch) then
Log.trace("Accepting patch without confirmation because it is empty") Log.trace("Accepting patch without confirmation because it is empty")
scheduleOneShotDisconnect()
return "Accept" return "Accept"
end end
-- Play solo auto-connect does not require confirmation -- Play solo auto-connect does not require confirmation
if self:isAutoConnectPlaytestServerAvailable() then if self:isAutoConnectPlaytestServerAvailable() then
Log.trace("Accepting patch without confirmation because play solo auto-connect is enabled") Log.trace("Accepting patch without confirmation because play solo auto-connect is enabled")
scheduleOneShotDisconnect()
return "Accept" return "Accept"
end end
@ -742,7 +733,6 @@ function App:startSession()
Log.trace( Log.trace(
"Accepting patch without confirmation because project has already been connected and behavior is set to Initial" "Accepting patch without confirmation because project has already been connected and behavior is set to Initial"
) )
scheduleOneShotDisconnect()
return "Accept" return "Accept"
end end
elseif confirmationBehavior == "Large Changes" then elseif confirmationBehavior == "Large Changes" then
@ -751,7 +741,6 @@ function App:startSession()
Log.trace( Log.trace(
"Accepting patch without confirmation because patch is small and behavior is set to Large Changes" "Accepting patch without confirmation because patch is small and behavior is set to Large Changes"
) )
scheduleOneShotDisconnect()
return "Accept" return "Accept"
end end
elseif confirmationBehavior == "Unlisted PlaceId" then elseif confirmationBehavior == "Unlisted PlaceId" then
@ -762,13 +751,11 @@ function App:startSession()
Log.trace( Log.trace(
"Accepting patch without confirmation because placeId is listed and behavior is set to Unlisted PlaceId" "Accepting patch without confirmation because placeId is listed and behavior is set to Unlisted PlaceId"
) )
scheduleOneShotDisconnect()
return "Accept" return "Accept"
end end
end end
elseif confirmationBehavior == "Never" then elseif confirmationBehavior == "Never" then
Log.trace("Accepting patch without confirmation because behavior is set to Never") Log.trace("Accepting patch without confirmation because behavior is set to Never")
scheduleOneShotDisconnect()
return "Accept" return "Accept"
end end
end end
@ -787,10 +774,12 @@ function App:startSession()
and datamodelUpdates.changedClassName == nil and datamodelUpdates.changedClassName == nil
then then
Log.trace("Accepting patch without confirmation because it only contains a datamodel name change") Log.trace("Accepting patch without confirmation because it only contains a datamodel name change")
scheduleOneShotDisconnect()
return "Accept" return "Accept"
end end
end end
else
Log.info("One-shot mode: skipping all auto-accept paths, requiring explicit confirmation")
end
self:setState({ self:setState({
connectingText = "Computing diff view...", connectingText = "Computing diff view...",
@ -845,6 +834,13 @@ function App:startSession()
end) end)
serveSession:setPatchUpdateCallback(function(instanceMap, patch) serveSession:setPatchUpdateCallback(function(instanceMap, patch)
-- ONE-SHOT MODE: Ignore all further changes during confirmation
-- They will be picked up on the next connection attempt
if Settings:get("oneShotSync") then
Log.info("One-shot mode: ignoring incoming changes during confirmation")
return
end
-- If all changes have been reverted, auto-accept the empty patch -- If all changes have been reverted, auto-accept the empty patch
if PatchSet.isEmpty(patch) then if PatchSet.isEmpty(patch) then
Log.trace("Patch became empty after merging, auto-accepting") Log.trace("Patch became empty after merging, auto-accepting")

View file

@ -78,6 +78,15 @@ function ServeSession.new(options)
end end
local function onChangesFlushed(patch) 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) self.__apiContext:write(patch)
end end
@ -211,7 +220,14 @@ function ServeSession:__onWebSocketMessage(messagesPacket)
end end
-- If we're already waiting for confirmation, merge into the patch being confirmed -- If we're already waiting for confirmation, merge into the patch being confirmed
-- UNLESS we're in One-shot mode, where we ignore all further changes
if self.__confirmingPatch ~= nil then if self.__confirmingPatch ~= nil then
if Settings:get("oneShotSync") then
Log.info("One-shot mode: ignoring incoming changes while confirming (will be picked up on next connection)")
self.__apiContext:setMessageCursor(messagesPacket.messageCursor)
return
end
Log.trace("Already confirming, merging into current patch") Log.trace("Already confirming, merging into current patch")
PatchSet.merge(self.__confirmingPatch, combinedPatch, self.__instanceMap) PatchSet.merge(self.__confirmingPatch, combinedPatch, self.__instanceMap)
-- Notify the UI to update the displayed patch -- Notify the UI to update the displayed patch