@el4cteo/rbx-studio-mcp 0.8.4 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +20 -3
  2. package/dist/bridge/rpc.js +1 -1
  3. package/dist/bridge/rpc.js.map +1 -1
  4. package/dist/index.js +7 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/apidump.js +75 -0
  7. package/dist/lib/apidump.js.map +1 -1
  8. package/dist/lib/errors.js +5 -4
  9. package/dist/lib/errors.js.map +1 -1
  10. package/dist/lib/format.js +92 -25
  11. package/dist/lib/format.js.map +1 -1
  12. package/dist/lib/notices.js +29 -0
  13. package/dist/lib/notices.js.map +1 -0
  14. package/dist/lib/protocol.js.map +1 -1
  15. package/dist/lib/sync.js +1141 -0
  16. package/dist/lib/sync.js.map +1 -0
  17. package/dist/lib/syncplan.js +338 -0
  18. package/dist/lib/syncplan.js.map +1 -0
  19. package/dist/lib/tool.js +9 -2
  20. package/dist/lib/tool.js.map +1 -1
  21. package/dist/tools/discover.js +6 -1
  22. package/dist/tools/discover.js.map +1 -1
  23. package/dist/tools/exec.js +5 -0
  24. package/dist/tools/exec.js.map +1 -1
  25. package/dist/tools/instances.js +2 -2
  26. package/dist/tools/instances.js.map +1 -1
  27. package/dist/tools/perf.js +28 -3
  28. package/dist/tools/perf.js.map +1 -1
  29. package/dist/tools/screenshot.js +7 -3
  30. package/dist/tools/screenshot.js.map +1 -1
  31. package/dist/tools/scripts.js +110 -28
  32. package/dist/tools/scripts.js.map +1 -1
  33. package/dist/tools/sync.js +169 -0
  34. package/dist/tools/sync.js.map +1 -0
  35. package/package.json +4 -4
  36. package/plugin/src/Config.luau +1 -1
  37. package/plugin/src/Dispatch.luau +131 -90
  38. package/plugin/src/ExecRuntime.luau +190 -168
  39. package/plugin/src/LogBuffer.luau +38 -9
  40. package/plugin/src/Paths.luau +42 -0
  41. package/plugin/src/Phrase.luau +41 -0
  42. package/plugin/src/ScriptEdit.luau +94 -8
  43. package/plugin/src/Serialize.luau +16 -1
  44. package/plugin/src/Transport.luau +5 -2
  45. package/plugin/src/Undo.luau +74 -10
  46. package/plugin/src/handlers/Capture.luau +818 -809
  47. package/plugin/src/handlers/Debug.luau +19 -16
  48. package/plugin/src/handlers/Discover.luau +31 -1
  49. package/plugin/src/handlers/Perf.luau +100 -21
  50. package/plugin/src/handlers/Scripts.luau +274 -90
  51. package/plugin/src/handlers/Sync.luau +968 -0
  52. package/plugin/src/init.server.luau +33 -2
  53. package/scripts/build.mjs +14 -0
  54. package/scripts/sync-fake.mjs +191 -0
  55. package/scripts/test-live-sync-scale.mjs +150 -0
  56. package/scripts/test-live-sync.mjs +232 -0
  57. package/scripts/test-live-tools.mjs +6 -1
  58. package/scripts/test-plugin.mjs +16 -0
  59. package/scripts/test-results.mjs +41 -0
  60. package/scripts/test-sync-more.mjs +228 -0
  61. package/scripts/test-sync.mjs +245 -0
  62. package/dist/tools/spatial.js +0 -135
  63. package/dist/tools/spatial.js.map +0 -1
  64. package/dist/tools/upload.js +0 -294
  65. package/dist/tools/upload.js.map +0 -1
@@ -41,6 +41,8 @@ local MAX_CONTEXT = 1000
41
41
 
42
42
  export type Entry = {
43
43
  sequence: number?,
44
+ id: number?,
45
+ updated: boolean?,
44
46
  level: string,
45
47
  message: string,
46
48
  timestamp: number,
@@ -101,6 +103,10 @@ local collecting: { string }? = nil
101
103
  local function pushEntry(entry: Entry)
102
104
  nextSequence += 1
103
105
  entry.sequence = nextSequence
106
+ entry.id = nextSequence
107
+ entry.message = string.sub(entry.message, 1, 2000)
108
+ if entry.stack then entry.stack = string.sub(entry.stack, 1, 4000) end
109
+ if entry.source then entry.source = string.sub(entry.source, 1, 500) end
104
110
  table.insert(entries, entry)
105
111
 
106
112
  if #entries > CAPACITY + SLACK then
@@ -133,18 +139,24 @@ function LogBuffer.pushClient(player: Player, payload: any)
133
139
  if payload.kind == "ready" then buffer.ready = true; return end
134
140
  if payload.kind == "trace" then
135
141
  if typeof(payload.message) ~= "string" or typeof(payload.stack) ~= "string" then return end
142
+ local traceMessage = string.sub(payload.message, 1, 2000)
136
143
  for index = #buffer.entries, math.max(1, #buffer.entries - CORRELATION_WINDOW + 1), -1 do
137
144
  local item = buffer.entries[index]
138
- if item.level == "error" and item.message == payload.message and item.stack == nil then
145
+ if item.level == "error" and item.message == string.sub(payload.message, 1, 2000) and item.stack == nil then
139
146
  item.stack = string.sub(payload.stack, 1, 4000)
140
147
  item.source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil
148
+ buffer.sequence += 1
149
+ item.sequence = buffer.sequence
150
+ item.updated = true
151
+ table.remove(buffer.entries, index)
152
+ table.insert(buffer.entries, item)
141
153
  return
142
154
  end
143
155
  end
144
156
  local orphanCount = 0
145
157
  for _ in buffer.orphan do orphanCount += 1 end
146
- if orphanCount < 20 or buffer.orphan[payload.message] ~= nil then
147
- buffer.orphan[payload.message] = { stack = string.sub(payload.stack, 1, 4000), source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil }
158
+ if orphanCount < 20 or buffer.orphan[traceMessage] ~= nil then
159
+ buffer.orphan[traceMessage] = { stack = string.sub(payload.stack, 1, 4000), source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil }
148
160
  end
149
161
  return
150
162
  end
@@ -154,6 +166,7 @@ function LogBuffer.pushClient(player: Player, payload: any)
154
166
  buffer.sequence += 1
155
167
  local entry: Entry = {
156
168
  sequence = buffer.sequence,
169
+ id = buffer.sequence,
157
170
  level = level,
158
171
  message = string.sub(payload.message, 1, 2000),
159
172
  timestamp = if typeof(payload.timestamp) == "number" and payload.timestamp >= 0 and payload.timestamp < 100000000000 then payload.timestamp else os.time(),
@@ -202,17 +215,20 @@ end
202
215
  ]]
203
216
  -- JSONEncode writes a Vector3 or an Instance as `null` without complaint, so
204
217
  -- anything JSON has no shape for is turned into its text first.
205
- local function plain(value: any, depth: number): any
218
+ local function plain(value: any, depth: number, budget: { nodes: number }): any
219
+ budget.nodes -= 1
220
+ if budget.nodes <= 0 then return "<truncated>" end
206
221
  local kind = typeof(value)
207
222
  if kind == "string" or kind == "number" or kind == "boolean" then
208
- return value
223
+ return if kind == "string" then string.sub(value, 1, MAX_CONTEXT) else value
209
224
  elseif kind == "table" then
210
225
  if depth >= 4 then
211
226
  return "<table>"
212
227
  end
213
228
  local copy = {}
214
229
  for key, item in value do
215
- copy[if typeof(key) == "number" then key else tostring(key)] = plain(item, depth + 1)
230
+ if budget.nodes <= 0 then copy["..."] = "<truncated>"; break end
231
+ copy[if typeof(key) == "number" then key else string.sub(tostring(key), 1, 100)] = plain(item, depth + 1, budget)
216
232
  end
217
233
  return copy
218
234
  elseif kind == "Instance" then
@@ -229,15 +245,17 @@ function LogBuffer.encodeContext(context: any): string?
229
245
  return nil
230
246
  end
231
247
  local ok, encoded = pcall(function()
232
- return HttpService:JSONEncode(plain(context, 0))
248
+ return HttpService:JSONEncode(plain(context, 0, {nodes = 50}))
233
249
  end)
234
250
  if not ok then
235
251
  return nil
236
252
  end
237
- return string.sub(encoded :: string, 1, MAX_CONTEXT)
253
+ if #encoded > MAX_CONTEXT then return HttpService:JSONEncode({truncated = string.sub(encoded, 1, 100)}) end
254
+ return encoded
238
255
  end
239
256
 
240
257
  local function push(message: string, messageType: Enum.MessageType, timestamp: number?, context: any?)
258
+ message = string.sub(message, 1, 2000)
241
259
  local level = LEVELS[messageType.Value] or "print"
242
260
  local entry: Entry = {
243
261
  level = level,
@@ -270,12 +288,20 @@ end
270
288
  two identical lines.
271
289
  ]]
272
290
  local function attachStack(message: string, stack: string, source: string?)
291
+ message = string.sub(message, 1, 2000)
292
+ stack = string.sub(stack, 1, 4000)
293
+ if source then source = string.sub(source, 1, 500) end
273
294
  local first = math.max(1, #entries - CORRELATION_WINDOW + 1)
274
295
  for index = #entries, first, -1 do
275
296
  local entry = entries[index]
276
297
  if entry.level == "error" and entry.message == message and entry.stack == nil then
277
298
  entry.stack = stack
278
299
  entry.source = source
300
+ nextSequence += 1
301
+ entry.sequence = nextSequence
302
+ entry.updated = true
303
+ table.remove(entries, index)
304
+ table.insert(entries, entry)
279
305
  return
280
306
  end
281
307
  end
@@ -336,7 +362,10 @@ function LogBuffer.start()
336
362
  local existing = recent.stack
337
363
  local existingFrames = if existing then #string.split(existing, "\n") else 0
338
364
  if existing == nil or #buffered > existingFrames then
339
- recent.stack = table.concat(buffered, "\n")
365
+ recent.stack = string.sub(table.concat(buffered, "\n"), 1, 4000)
366
+ nextSequence += 1
367
+ recent.sequence = nextSequence
368
+ recent.updated = true
340
369
  end
341
370
  end
342
371
  end
@@ -67,6 +67,40 @@ local function childrenByName(parent: Instance, memo: NameIndex?): { [string]: {
67
67
  table.insert(list, child)
68
68
  end
69
69
 
70
+ --[[
71
+ Same-named siblings put into a fixed order.
72
+
73
+ `GetChildren` does not return a stable order (see `stableOrder` in
74
+ Discover), so `Part[2]` could name a different part on the call that
75
+ reads the path back than on the one that printed it. The engine's debug
76
+ id is fixed for an instance's lifetime, which makes it the tiebreak.
77
+
78
+ Guarded, because this module also runs in the client relay, where
79
+ `GetDebugId` is not callable -- there the order is simply the engine's,
80
+ as it always was, rather than an error on every path.
81
+ ]]
82
+ for _, list in byName do
83
+ if #list > 1 then
84
+ local ids: { [Instance]: string } = {}
85
+ local stable = true
86
+ for _, child in list do
87
+ local ok, id = pcall(function()
88
+ return child:GetDebugId()
89
+ end)
90
+ if not ok then
91
+ stable = false
92
+ break
93
+ end
94
+ ids[child] = id
95
+ end
96
+ if stable then
97
+ table.sort(list, function(a: Instance, b: Instance): boolean
98
+ return ids[a] < ids[b]
99
+ end)
100
+ end
101
+ end
102
+ end
103
+
70
104
  if memo then
71
105
  memo[parent] = byName
72
106
  end
@@ -315,6 +349,14 @@ end
315
349
  The index is positional, so it shifts if same-named siblings are inserted or
316
350
  removed between calls. Read a fresh path after any structural change.
317
351
  ]]
352
+ --[[
353
+ The position among same-named siblings, or nil when the name is unique --
354
+ the `n` in `Name[n]`. Exposed for `sync`, which names files after it.
355
+ ]]
356
+ function Paths.ordinal(instance: Instance, memo: NameIndex?): number?
357
+ return siblingIndex(instance, memo)
358
+ end
359
+
318
360
  function Paths.of(instance: Instance, memo: NameIndex?): string
319
361
  --[[
320
362
  Refuses anything that is not an Instance, rather than duck-typing it.
@@ -91,6 +91,42 @@ type Describer = (params: { [string]: any }) -> string
91
91
  playtest is about a mode, a find is about what was searched for.
92
92
  ]]
93
93
  local DESCRIBERS: { [string]: Describer } = {
94
+ ["sync.scan"] = function(params)
95
+ return if params.revisions == false then "Check script layout for sync" else "Scan scripts for sync"
96
+ end,
97
+ ["sync.read"] = function(params)
98
+ return "Read " .. subjectOf(params.paths, "script") .. " for sync"
99
+ end,
100
+ ["sync.apply"] = function(params)
101
+ local total = count(params.writes) + count(params.creates) + count(params.deletes) + count(params.moves)
102
+ return string.format("Sync %d change%s from files", total, if total == 1 then "" else "s")
103
+ end,
104
+ ["sync.shape"] = function()
105
+ return "Check script layout for sync"
106
+ end,
107
+ ["sync.revisions"] = function()
108
+ return "Check script revisions"
109
+ end,
110
+ ["sync.changes"] = function()
111
+ return "Check for script changes"
112
+ end,
113
+ ["sync.stop"] = function()
114
+ return "Stop sync tracking"
115
+ end,
116
+ ["sync.log"] = function()
117
+ return "Sync note"
118
+ end,
119
+ ["sync.classes"] = function(params)
120
+ return "List classes in " .. (leaf(params.path) or "tree")
121
+ end,
122
+ ["sync.export"] = function(params)
123
+ return "Export " .. (leaf(params.path) or "tree") .. " to a build file"
124
+ end,
125
+ ["sync.build"] = function(params)
126
+ local spec = params.spec
127
+ local name = if typeof(spec) == "table" then spec.name or spec.className else nil
128
+ return "Build " .. tostring(name or "tree") .. " from a build file"
129
+ end,
94
130
  ["script.read"] = function(params)
95
131
  return "Read " .. subjectOf(params.paths, "script")
96
132
  end,
@@ -756,6 +792,8 @@ local KINDS: { [string]: string } = {
756
792
  terrain = "write",
757
793
  generate = "write",
758
794
  data = "write",
795
+ -- Applies files to the place; the reads inside the group are carved out below.
796
+ sync = "write",
759
797
  }
760
798
 
761
799
  function Phrase.kindOf(op: string): string
@@ -770,6 +808,9 @@ function Phrase.kindOf(op: string): string
770
808
  if op == "audio.inspect" then
771
809
  return "read"
772
810
  end
811
+ if op == "sync.scan" or op == "sync.shape" or op == "sync.revisions" or op == "sync.read" or op == "sync.changes" or op == "sync.classes" or op == "sync.export" then
812
+ return "read"
813
+ end
773
814
  return KINDS[group] or "read"
774
815
  end
775
816
 
@@ -38,6 +38,70 @@ function ScriptEdit.read(target: LuaSourceContainer): string
38
38
  return (target :: SourceContainer).Source
39
39
  end
40
40
 
41
+ --[[
42
+ A short fingerprint of a script's source, for detecting that it moved.
43
+
44
+ Handed out by `script_read` and passed back to `script_edit`, and kept by
45
+ `sync` to tell which side of a synced script changed. `edit`, which refuses to write when
46
+ the live source no longer matches. That is the only thing standing between
47
+ two agents on one place and a silent overwrite: a line-range edit computed
48
+ against source somebody has since changed still applies cleanly, it just
49
+ applies to the wrong lines, and nothing anywhere reports it.
50
+
51
+ FNV-1a over the whole string, with the length appended. Not a security
52
+ hash and does not need to be -- it is guarding against ordinary concurrent
53
+ editing, not against someone constructing a collision. The length is there
54
+ because it is free and rules out the whole class of same-length accidents.
55
+
56
+ The multiply is split into 16-bit halves on purpose. `hash * 16777619` with
57
+ a 32-bit hash reaches 2^56, past the 2^53 where doubles stop being exact,
58
+ so the low bits -- the ones that carry the mixing -- would be quietly
59
+ rounded away.
60
+ ]]
61
+ function ScriptEdit.fingerprint(source: string): string
62
+ local hash = 2166136261
63
+ local length = #source
64
+ local index = 1
65
+ while index <= length do
66
+ local last = math.min(index + 511, length)
67
+ local chunk = { string.byte(source, index, last) }
68
+ for _, byte in chunk do
69
+ hash = bit32.bxor(hash, byte)
70
+ local low = bit32.band(hash, 0xFFFF)
71
+ local high = bit32.rshift(hash, 16)
72
+ -- 16777619 == 0x01000193, so 0x0193 is 403 and 0x0100 is 256.
73
+ hash = bit32.band(low * 403 + bit32.lshift(bit32.band(high * 403 + low * 256, 0xFFFF), 16), 0xFFFFFFFF)
74
+ end
75
+ index = last + 1
76
+ end
77
+ return string.format("%08x-%x", hash, length)
78
+ end
79
+
80
+ --[[
81
+ A script's revision, computed once per distinct text.
82
+
83
+ Fingerprinting walks every byte in Luau, and `sync` asks for the revision of
84
+ every script in a place on each run -- so a place with megabytes of code paid
85
+ for hashing all of it after every save, to learn that one file changed. The
86
+ text is kept beside its revision per script, and a lookup whose text is the
87
+ same string skips the hash: Luau interns strings, so that comparison is a
88
+ pointer check, not a second pass over the source.
89
+
90
+ Weak keys, so a deleted script's entry goes with it.
91
+ ]]
92
+ local revisions: { [Instance]: { source: string, revision: string } } = setmetatable({}, { __mode = "k" }) :: any
93
+
94
+ function ScriptEdit.revisionOf(target: LuaSourceContainer, source: string?): string
95
+ local text = source or ScriptEdit.read(target)
96
+ local cached = revisions[target]
97
+ if cached ~= nil and cached.source == text then
98
+ return cached.revision
99
+ end
100
+ local revision = ScriptEdit.fingerprint(text)
101
+ revisions[target] = { source = text, revision = revision }
102
+ return revision
103
+ end
104
+
41
105
  --[[
42
106
  Makes sure a write that reported success actually stuck.
43
107
 
@@ -59,11 +123,16 @@ end
59
123
  caused this is only reachable when the USER opens one in the same moment we
60
124
  write to it -- rare, and cheap enough to cover anyway. One settle, one read,
61
125
  and a rewrite if it did not take.
126
+
127
+ A rewrite only ever replaces `original`, the text our write replaced. That
128
+ is what a finishing load puts back. Anything else in the buffer is somebody
129
+ typing after the write landed, and rewriting it "to make ours stick" would
130
+ delete their work -- so that is reported instead, and their text is kept.
62
131
  ]]
63
132
  local SETTLE = 0.2
64
133
 
65
- local function confirm(target: LuaSourceContainer, produced: string?)
66
- if produced == nil then
134
+ local function confirm(target: LuaSourceContainer, produced: string?, original: string?)
135
+ if produced == nil or produced == original then
67
136
  return
68
137
  end
69
138
 
@@ -86,20 +155,35 @@ local function confirm(target: LuaSourceContainer, produced: string?)
86
155
  -- repeating will not fix.
87
156
  for _ = 1, 3 do
88
157
  task.wait(SETTLE)
89
- if ScriptEdit.read(target) == produced then
158
+ local current = ScriptEdit.read(target)
159
+ if current == produced then
90
160
  return
91
161
  end
162
+ if current ~= original then
163
+ break
164
+ end
92
165
  pcall(function()
93
- ScriptEditorService:UpdateSourceAsync(target, function()
94
- return produced
166
+ ScriptEditorService:UpdateSourceAsync(target, function(source)
167
+ -- Checked again at commit time: the user may have typed since the read.
168
+ return if source == original then produced :: string else source
95
169
  end)
96
170
  end)
97
171
  end
172
+
173
+ if ScriptEdit.read(target) ~= produced then
174
+ Dispatch.fail(
175
+ "VERIFY_FAILED",
176
+ string.format("%s changed while the write was being confirmed.", target:GetFullName()),
177
+ "The newer text was kept, not overwritten. Read the script again before retrying."
178
+ )
179
+ end
98
180
  end
99
181
 
100
182
  --[[
101
- Replaces source through the editor. `transform` receives the current text and
102
- returns the replacement; returning the input unchanged is a no-op.
183
+ Replaces source through the editor. `transform` receives the current text --
184
+ read at commit time, so it is where a "has this changed since I read it"
185
+ check belongs -- and returns the replacement; returning the input unchanged
186
+ is a no-op.
103
187
 
104
188
  `transform` is allowed to raise a structured Dispatch failure -- a bad line
105
189
  range is only discoverable once the authoritative source is in hand. Such a
@@ -115,10 +199,12 @@ function ScriptEdit.write(target: LuaSourceContainer, transform: (string) -> str
115
199
  local pending: any = nil
116
200
 
117
201
  local produced: string? = nil
202
+ local original: string? = nil
118
203
  local ok, err = pcall(function()
119
204
  ScriptEditorService:UpdateSourceAsync(target, function(source)
120
205
  local applied, result = pcall(transform, source)
121
206
  if applied then
207
+ original = source
122
208
  produced = result
123
209
  return result
124
210
  end
@@ -131,7 +217,7 @@ function ScriptEdit.write(target: LuaSourceContainer, transform: (string) -> str
131
217
  error(pending, 0)
132
218
  end
133
219
  if ok then
134
- confirm(target, produced)
220
+ confirm(target, produced, original)
135
221
  return
136
222
  end
137
223
 
@@ -413,7 +413,22 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
413
413
  end
414
414
  -- 0-255 is a common mistake and unambiguous to detect, so accept it.
415
415
  local scale = if numbers[1] > 1 or numbers[2] > 1 or numbers[3] > 1 then 255 else 1
416
- return true, Color3.new(numbers[1] / scale, numbers[2] / scale, numbers[3] / scale), nil
416
+ --[[
417
+ Snapped back to an exact 1/255 step when the text is that step
418
+ rounded to PRECISION places.
419
+
420
+ Part colours are stored as bytes. "0.7686" is how 196/255 prints,
421
+ but it is a hair under it, and the engine truncates on the way in
422
+ -- so a colour read and written back came out as 195, one step
423
+ darker each round trip. A value genuinely between steps, like 0.5,
424
+ is further than half a printed digit from one and is left alone.
425
+ ]]
426
+ local function component(value: number): number
427
+ local unit = value / scale
428
+ local step = math.round(unit * 255) / 255
429
+ return if math.abs(unit - step) <= 0.5 * 10 ^ -PRECISION then step else unit
430
+ end
431
+ return true, Color3.new(component(numbers[1]), component(numbers[2]), component(numbers[3])), nil
417
432
  end
418
433
  if valueType == "BrickColor" then
419
434
  -- Cast because the API definitions type this overload as a literal union
@@ -26,7 +26,9 @@ local Transport = {}
26
26
  export type Status = "disconnected" | "connecting" | "connected"
27
27
 
28
28
  type Callbacks = {
29
- onCommand: (id: string, op: string, params: { [string]: any }?) -> (),
29
+ -- `deadlineMs` is when the server stops waiting for the reply, on this
30
+ -- machine's clock: the server sends a duration, never a time (see Command.timeoutMs).
31
+ onCommand: (id: string, op: string, params: { [string]: any }?, deadlineMs: number?) -> (),
30
32
  onStatus: (status: Status, detail: string?) -> (),
31
33
  --[[
32
34
  Anything the bridge wants to tell this plugin that is not a command.
@@ -243,7 +245,8 @@ local function handleFrame(callbacks: Callbacks, frame: { [string]: any })
243
245
  local id = frame.id
244
246
  local op = frame.op
245
247
  if typeof(id) == "string" and typeof(op) == "string" then
246
- callbacks.onCommand(id, op, frame.params)
248
+ local deadline = if typeof(frame.timeoutMs) == "number" then DateTime.now().UnixTimestampMillis + frame.timeoutMs else nil
249
+ callbacks.onCommand(id, op, frame.params, deadline)
247
250
  return
248
251
  end
249
252
  if typeof(frame.event) == "string" then
@@ -18,6 +18,8 @@
18
18
 
19
19
  local ChangeHistoryService = game:GetService("ChangeHistoryService")
20
20
 
21
+ local Dispatch = require(script.Parent.Dispatch)
22
+
21
23
  local Undo = {}
22
24
 
23
25
  --[[
@@ -132,13 +134,57 @@ end
132
134
  is something tools tell the agent, and therefore the user, so it has to be
133
135
  observed rather than assumed: a claim of "one Ctrl+Z" that silently degrades
134
136
  to no undo entry at all is worse than no claim.
137
+
138
+ Refusing instead is not an option either: Studio opens no recording at all
139
+ inside a running playtest (measured), so a refusal would make every write
140
+ tool unusable against the runtime session.
141
+ ]]
142
+
143
+ -- Opens a recording, or nil. Guarded because an engine error here must not
144
+ -- escape with the lock held.
145
+ local function begin(name: string, displayName: string): string?
146
+ local ok, identifier = pcall(function()
147
+ return ChangeHistoryService:TryBeginRecording(name, displayName)
148
+ end)
149
+ return if ok then identifier else nil
150
+ end
151
+
152
+ --[[
153
+ Runs `body` after the lock is ours, unless the request expired while it
154
+ waited. The server stops waiting at its timeout, so work that starts later
155
+ would be reported to nobody -- and an agent told "timed out" is likely to
156
+ retry it.
135
157
  ]]
158
+ local function run<T>(body: () -> T): (boolean, any)
159
+ return pcall(function()
160
+ Dispatch.checkDeadline()
161
+ return body()
162
+ end)
163
+ end
164
+
165
+ -- Closes a recording. A failure here is reported, with the lock released, as
166
+ -- an outcome the agent must check rather than assume.
167
+ local function finish(identifier: string, operation: Enum.FinishRecordingOperation, acquired: boolean)
168
+ local ok, err = pcall(function()
169
+ ChangeHistoryService:FinishRecording(identifier, operation)
170
+ end)
171
+ if ok then
172
+ return
173
+ end
174
+ release(acquired)
175
+ Dispatch.fail(
176
+ "UNDO_FAILED",
177
+ string.format("Studio could not close the undo recording: %s", tostring(err)),
178
+ "The change may or may not have applied. Inspect the affected instances before retrying."
179
+ )
180
+ end
181
+
136
182
  function Undo.record<T>(name: string, displayName: string, body: () -> T): (T, boolean)
137
183
  local acquired = acquire()
138
184
 
139
- local identifier = ChangeHistoryService:TryBeginRecording(name, displayName)
185
+ local identifier = begin(name, displayName)
140
186
  if not identifier then
141
- local ok, result = pcall(body)
187
+ local ok, result = run(body)
142
188
  release(acquired)
143
189
  if not ok then
144
190
  error(result, 0)
@@ -146,15 +192,15 @@ function Undo.record<T>(name: string, displayName: string, body: () -> T): (T, b
146
192
  return result, false
147
193
  end
148
194
 
149
- local ok, result = pcall(body)
195
+ local ok, result = run(body)
150
196
  if ok then
151
- ChangeHistoryService:FinishRecording(identifier, Enum.FinishRecordingOperation.Commit)
197
+ finish(identifier, Enum.FinishRecordingOperation.Commit, acquired)
152
198
  release(acquired)
153
199
  return result, true
154
200
  end
155
201
 
156
- ChangeHistoryService:FinishRecording(identifier, Enum.FinishRecordingOperation.Cancel)
157
- dropRedo()
202
+ finish(identifier, Enum.FinishRecordingOperation.Cancel, acquired)
203
+ pcall(dropRedo)
158
204
  release(acquired)
159
205
  error(result, 0)
160
206
  end
@@ -167,9 +213,9 @@ end
167
213
  function Undo.recordPartial<T>(name: string, displayName: string, body: () -> T): (T, boolean)
168
214
  local acquired = acquire()
169
215
 
170
- local identifier = ChangeHistoryService:TryBeginRecording(name, displayName)
216
+ local identifier = begin(name, displayName)
171
217
  if not identifier then
172
- local ok, result = pcall(body)
218
+ local ok, result = run(body)
173
219
  release(acquired)
174
220
  if not ok then
175
221
  error(result, 0)
@@ -177,8 +223,8 @@ function Undo.recordPartial<T>(name: string, displayName: string, body: () -> T)
177
223
  return result, false
178
224
  end
179
225
 
180
- local ok, result = pcall(body)
181
- ChangeHistoryService:FinishRecording(identifier, Enum.FinishRecordingOperation.Commit)
226
+ local ok, result = run(body)
227
+ finish(identifier, Enum.FinishRecordingOperation.Commit, acquired)
182
228
  release(acquired)
183
229
  if not ok then
184
230
  error(result, 0)
@@ -186,4 +232,22 @@ function Undo.recordPartial<T>(name: string, displayName: string, body: () -> T)
186
232
  return result, true
187
233
  end
188
234
 
235
+ --[[
236
+ Holds the mutation lock without opening a recording.
237
+
238
+ For script source, whose undo is the script editor's own, per document --
239
+ a recording would add an empty step, not make the edit undoable. The lock
240
+ is what matters: two batches writing the same scripts must not interleave
241
+ their writes and their rollbacks.
242
+ ]]
243
+ function Undo.exclusive<T>(body: () -> T): T
244
+ local acquired = acquire()
245
+ local ok, result = run(body)
246
+ release(acquired)
247
+ if not ok then
248
+ error(result, 0)
249
+ end
250
+ return result
251
+ end
252
+
189
253
  return Undo