@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
@@ -1,90 +1,131 @@
1
- --!strict
2
- --[[
3
- Handler registry and the single error boundary around every command.
4
-
5
- Handlers raise failures with `Dispatch.fail(code, message, hint)`. The hint is
6
- forwarded verbatim to the agent, so it should say what to do next ("call find
7
- first to get a valid path"), not just what went wrong.
8
- ]]
9
-
10
- local Dispatch = {}
11
-
12
- export type Error = {
13
- code: string,
14
- message: string,
15
- hint: string?,
16
- }
17
-
18
- export type Handler = (params: { [string]: any }) -> any
19
-
20
- local handlers: { [string]: Handler } = {}
21
-
22
- function Dispatch.register(op: string, handler: Handler)
23
- handlers[op] = handler
24
- end
25
-
26
- --[[
27
- Registers every entry of `map` under the prefix, e.g. `registerAll("script",
28
- { read = fn })` exposes "script.read".
29
- ]]
30
- function Dispatch.registerAll(prefix: string, map: { [string]: Handler })
31
- for name, handler in map do
32
- handlers[prefix .. "." .. name] = handler
33
- end
34
- end
35
-
36
- --[[
37
- Raises a structured failure. Always call this rather than `error("...")` so
38
- the agent receives a code it can branch on and a hint it can act on.
39
- ]]
40
- function Dispatch.fail(code: string, message: string, hint: string?): never
41
- error({ code = code, message = message, hint = hint }, 0)
42
- end
43
-
44
- export type Result = {
45
- id: string,
46
- ok: boolean,
47
- data: any?,
48
- error: Error?,
49
- }
50
-
51
- --[[
52
- Runs one command and returns the frame to POST back. Never throws: an
53
- unhandled error inside a handler becomes a HANDLER_ERROR result, because a
54
- crashed dispatch would leave the server's promise hanging until it times out.
55
- ]]
56
- function Dispatch.invoke(id: string, op: string, params: { [string]: any }?): Result
57
- local handler = handlers[op]
58
- if not handler then
59
- return {
60
- id = id,
61
- ok = false,
62
- error = {
63
- code = "UNKNOWN_OP",
64
- message = string.format('This Studio plugin has no handler for "%s".', op),
65
- hint = "The plugin is older than the MCP server. Update the Studio MCP "
66
- .. "plugin to the version matching your server.",
67
- },
68
- }
69
- end
70
-
71
- local ok, result = pcall(handler, params or {})
72
- if ok then
73
- return { id = id, ok = true, data = result }
74
- end
75
-
76
- if typeof(result) == "table" and (result :: any).code then
77
- return { id = id, ok = false, error = result :: Error }
78
- end
79
-
80
- return {
81
- id = id,
82
- ok = false,
83
- error = {
84
- code = "HANDLER_ERROR",
85
- message = string.format('"%s" failed: %s', op, tostring(result)),
86
- },
87
- }
88
- end
89
-
90
- return Dispatch
1
+ --!strict
2
+ --[[
3
+ Handler registry and the single error boundary around every command.
4
+
5
+ Handlers raise failures with `Dispatch.fail(code, message, hint)`. The hint is
6
+ forwarded verbatim to the agent, so it should say what to do next ("call find
7
+ first to get a valid path"), not just what went wrong.
8
+ ]]
9
+
10
+ local Dispatch = {}
11
+
12
+ export type Error = {
13
+ code: string,
14
+ message: string,
15
+ hint: string?,
16
+ }
17
+
18
+ export type Handler = (params: { [string]: any }) -> any
19
+
20
+ local handlers: { [string]: Handler } = {}
21
+
22
+ --[[
23
+ When the server stops waiting for each running command, keyed by the
24
+ command's thread.
25
+
26
+ The server drops a timed-out command it has not delivered yet, but one
27
+ already delivered can still be sitting behind the mutation lock, or behind
28
+ a slow editor write. Starting it then does work nobody will hear about --
29
+ and the agent, told "timed out", may be retrying it at that moment. So the
30
+ deadline travels with the command and is checked where work would begin.
31
+
32
+ Both clocks are this machine's, so there is no skew to allow for. Weak keys,
33
+ so a thread that dies mid-command does not leave its entry behind.
34
+ ]]
35
+ local deadlines: { [thread]: number } = setmetatable({}, { __mode = "k" }) :: any
36
+
37
+ function Dispatch.deadline(): number?
38
+ return deadlines[coroutine.running()]
39
+ end
40
+
41
+ --[[
42
+ Refuses to go on once the command's deadline has passed. Pass the deadline
43
+ explicitly from code that runs on another thread -- an engine callback, or
44
+ a spawned task -- which the table above cannot see.
45
+ ]]
46
+ function Dispatch.checkDeadline(explicit: number?)
47
+ local deadline = explicit or Dispatch.deadline()
48
+ if deadline and DateTime.now().UnixTimestampMillis >= deadline then
49
+ Dispatch.fail(
50
+ "DEADLINE_EXPIRED",
51
+ "The request timed out before Studio started this step, so it was not done.",
52
+ "Earlier steps of the same request may have applied. Check the current state before retrying."
53
+ )
54
+ end
55
+ end
56
+
57
+ function Dispatch.register(op: string, handler: Handler)
58
+ handlers[op] = handler
59
+ end
60
+
61
+ --[[
62
+ Registers every entry of `map` under the prefix, e.g. `registerAll("script",
63
+ { read = fn })` exposes "script.read".
64
+ ]]
65
+ function Dispatch.registerAll(prefix: string, map: { [string]: Handler })
66
+ for name, handler in map do
67
+ handlers[prefix .. "." .. name] = handler
68
+ end
69
+ end
70
+
71
+ --[[
72
+ Raises a structured failure. Always call this rather than `error("...")` so
73
+ the agent receives a code it can branch on and a hint it can act on.
74
+ ]]
75
+ function Dispatch.fail(code: string, message: string, hint: string?): never
76
+ error({ code = code, message = message, hint = hint }, 0)
77
+ end
78
+
79
+ export type Result = {
80
+ id: string,
81
+ ok: boolean,
82
+ data: any?,
83
+ error: Error?,
84
+ }
85
+
86
+ --[[
87
+ Runs one command and returns the frame to POST back. Never throws: an
88
+ unhandled error inside a handler becomes a HANDLER_ERROR result, because a
89
+ crashed dispatch would leave the server's promise hanging until it times out.
90
+ ]]
91
+ function Dispatch.invoke(id: string, op: string, params: { [string]: any }?, deadlineMs: number?): Result
92
+ local handler = handlers[op]
93
+ if not handler then
94
+ return {
95
+ id = id,
96
+ ok = false,
97
+ error = {
98
+ code = "UNKNOWN_OP",
99
+ message = string.format('This Studio plugin has no handler for "%s".', op),
100
+ hint = "The plugin is older than the MCP server. Update the Studio MCP "
101
+ .. "plugin to the version matching your server.",
102
+ },
103
+ }
104
+ end
105
+
106
+ local thread = coroutine.running()
107
+ deadlines[thread] = deadlineMs
108
+ local ok, result = pcall(function()
109
+ Dispatch.checkDeadline()
110
+ return handler(params or {})
111
+ end)
112
+ deadlines[thread] = nil
113
+ if ok then
114
+ return { id = id, ok = true, data = result }
115
+ end
116
+
117
+ if typeof(result) == "table" and (result :: any).code then
118
+ return { id = id, ok = false, error = result :: Error }
119
+ end
120
+
121
+ return {
122
+ id = id,
123
+ ok = false,
124
+ error = {
125
+ code = "HANDLER_ERROR",
126
+ message = string.format('"%s" failed: %s', op, tostring(result)),
127
+ },
128
+ }
129
+ end
130
+
131
+ return Dispatch
@@ -1,168 +1,190 @@
1
- --!strict
2
- -- Shared bounded execution results in the VM that calls run.
3
- local LogService = game:GetService("LogService")
4
- local LogBuffer = require(script.Parent.LogBuffer)
5
- local Serialize = require(script.Parent.Serialize)
6
- -- A chunk that prints in a loop would otherwise return a response no context
7
- -- window can hold.
8
- local MAX_OUTPUT_LINES = 200
9
- local MAX_RETURN_VALUES = 10
10
-
11
- -- Returned tables are walked rather than counted, but not without limit: a
12
- -- chunk that returns a whole config tree, or one with a cycle in it, must not be
13
- -- able to produce a response nothing can read.
14
- local MAX_TABLE_ENTRIES = 50
15
- local MAX_TABLE_DEPTH = 4
16
-
17
- local function describe(value: any, depth: number?, seen: { [any]: boolean }?): any
18
- local level = depth or 0
19
- local visited = seen or {}
20
- local kind = typeof(value)
21
-
22
- if kind == "function" or kind == "thread" then
23
- return string.format("<%s>", kind)
24
- end
25
- if kind ~= "table" then
26
- return Serialize.value(value)
27
- end
28
-
29
- local source = value :: { [any]: any }
30
- -- Named separately from the depth cap, because they mean opposite things to
31
- -- a reader. The depth cap says "there is more below this"; a cycle says
32
- -- "this is the table you are already inside". Reporting the second as the
33
- -- first printed `t.self.self.self` as three distinct nested tables, which
34
- -- reads as real structure that does not exist.
35
- if visited[source] then
36
- return "<circular reference>"
37
- end
38
- if level >= MAX_TABLE_DEPTH then
39
- return "<nested table>"
40
- end
41
- visited[source] = true
42
-
43
- local count = 0
44
- for _ in source do
45
- count += 1
46
- end
47
-
48
- -- Arrays keep their shape so they read as lists on the other side; anything
49
- -- with non-sequential keys becomes a map with stringified keys.
50
- if count == #source then
51
- local list: { any } = {}
52
- for index, item in ipairs(source) do
53
- if index > MAX_TABLE_ENTRIES then
54
- table.insert(list, string.format("<%d more>", count - MAX_TABLE_ENTRIES))
55
- break
56
- end
57
- table.insert(list, describe(item, level + 1, visited))
58
- end
59
- -- Cleared on the way out so the same table appearing twice side by side
60
- -- still renders twice; only a table containing itself is a cycle.
61
- visited[source] = nil
62
- return list
63
- end
64
-
65
- local map: { [string]: any } = {}
66
- local shown = 0
67
- for key, item in source do
68
- shown += 1
69
- if shown > MAX_TABLE_ENTRIES then
70
- map["..."] = string.format("<%d more>", count - MAX_TABLE_ENTRIES)
71
- break
72
- end
73
- map[tostring(key)] = describe(item, level + 1, visited)
74
- end
75
- visited[source] = nil
76
- return map
77
- end
78
-
79
- local Runtime = {}
80
- Runtime.describe = describe
81
- function Runtime.run(chunk: () -> ...any, timeout: number?): { [string]: any }
82
- -- Captured rather than inferred: a chunk's prints are usually the point, and
83
- -- reading them back from the log afterwards would also pick up whatever else
84
- -- the place logged in the meantime.
85
- local output: { { [string]: any } } = {}
86
- local connection = LogService.MessageOut:Connect(function(message, messageType, context)
87
- if #output < MAX_OUTPUT_LINES then
88
- -- A structured log's context rides on the line, as it does in `console`.
89
- local encoded = LogBuffer.encodeContext(context)
90
- if encoded then
91
- message ..= " " .. encoded
92
- end
93
- table.insert(output, {
94
- message = message,
95
- level = if messageType == Enum.MessageType.MessageError
96
- then "error"
97
- elseif messageType == Enum.MessageType.MessageWarning then "warning"
98
- elseif messageType == Enum.MessageType.MessageInfo then "info"
99
- else "print",
100
- })
101
- end
102
- end)
103
-
104
- local started = os.clock()
105
- local results: any = nil
106
- if timeout then
107
- local worker = task.spawn(function() results = table.pack(pcall(chunk :: any)) end)
108
- local deadline = started + timeout
109
- while results == nil and os.clock() < deadline do task.wait() end
110
- if results == nil then
111
- task.cancel(worker)
112
- results = table.pack(false, "CLIENT_TIMEOUT: execution exceeded " .. tostring(timeout) .. "s")
113
- end
114
- else
115
- results = table.pack(pcall(chunk :: () -> ...any))
116
- end
117
- local elapsed = os.clock() - started
118
-
119
- --[[
120
- MessageOut is deferred: `print` returns before the event fires, so
121
- disconnecting straight after the chunk finished captured nothing at all.
122
- A short settle picks the lines up.
123
-
124
- It is bounded and stops as soon as the flow stops, rather than waiting a
125
- fixed period, so a chunk that logged nothing costs almost no time and one
126
- that logged plenty is not truncated.
127
- ]]
128
- local settleDeadline = os.clock() + 0.5
129
- local seen = #output
130
- local quietFrames = 0
131
- while os.clock() < settleDeadline and quietFrames < 3 do
132
- task.wait()
133
- if #output == seen then
134
- quietFrames += 1
135
- else
136
- seen = #output
137
- quietFrames = 0
138
- end
139
- end
140
-
141
- connection:Disconnect()
142
-
143
- if not results[1] then
144
- return {
145
- ok = false,
146
- error = tostring(results[2]),
147
- output = output,
148
- milliseconds = math.floor(elapsed * 1000 + 0.5),
149
- }
150
- end
151
-
152
- local returned: { any } = {}
153
- for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
154
- -- A nil return is written out rather than dropped. `table.insert` of nil
155
- -- does nothing, so `return a, nil, b` came back as two values and every
156
- -- one after the nil shifted into the wrong position.
157
- local value = results[index]
158
- table.insert(returned, if value == nil then "nil" else describe(value))
159
- end
160
-
161
- return {
162
- ok = true,
163
- returned = returned,
164
- output = output,
165
- milliseconds = math.floor(elapsed * 1000 + 0.5),
166
- }
167
- end
168
- return Runtime
1
+ --!strict
2
+ -- Shared bounded execution results in the VM that calls run.
3
+ local LogService = game:GetService("LogService")
4
+ local LogBuffer = require(script.Parent.LogBuffer)
5
+ local Serialize = require(script.Parent.Serialize)
6
+ -- A chunk that prints in a loop would otherwise return a response no context
7
+ -- window can hold.
8
+ local MAX_OUTPUT_LINES = 200
9
+ local MAX_RETURN_VALUES = 10
10
+
11
+ -- Returned tables are walked rather than counted, but not without limit: a
12
+ -- chunk that returns a whole config tree, or one with a cycle in it, must not be
13
+ -- able to produce a response nothing can read.
14
+ local MAX_TABLE_ENTRIES = 50
15
+ local MAX_TABLE_DEPTH = 4
16
+ type Budget = { nodes: number, chars: number }
17
+
18
+ local function describe(value: any, depth: number?, seen: { [any]: boolean }?, shared: Budget?): any
19
+ local budget = shared or {nodes = 300, chars = 10000}
20
+ budget.nodes -= 1
21
+ budget.chars -= 30
22
+ if budget.nodes < 0 or budget.chars < 100 then return "<budget exceeded>" end
23
+ local level = depth or 0
24
+ local visited = seen or {}
25
+ local kind = typeof(value)
26
+
27
+ if kind == "function" or kind == "thread" then
28
+ return string.format("<%s>", kind)
29
+ end
30
+ if kind ~= "table" then
31
+ local result = Serialize.value(value)
32
+ if typeof(result) == "string" then
33
+ local cap = math.min(2000, math.floor(budget.chars / 6))
34
+ budget.chars -= math.min(#result, cap) * 6
35
+ return if #result > cap then string.sub(result, 1, cap) .. "<truncated>" else result
36
+ end
37
+ return result
38
+ end
39
+
40
+ local source = value :: { [any]: any }
41
+ -- Named separately from the depth cap, because they mean opposite things to
42
+ -- a reader. The depth cap says "there is more below this"; a cycle says
43
+ -- "this is the table you are already inside". Reporting the second as the
44
+ -- first printed `t.self.self.self` as three distinct nested tables, which
45
+ -- reads as real structure that does not exist.
46
+ if visited[source] then
47
+ return "<circular reference>"
48
+ end
49
+ if level >= MAX_TABLE_DEPTH then
50
+ return "<nested table>"
51
+ end
52
+ visited[source] = true
53
+
54
+ local count = 0
55
+ local isArray = true
56
+ for key in source do
57
+ if typeof(key) ~= "number" or key < 1 or key > #source or key % 1 ~= 0 then isArray = false end
58
+ count += 1
59
+ if count > MAX_TABLE_ENTRIES then break end
60
+ end
61
+
62
+ -- Arrays keep their shape so they read as lists on the other side; anything
63
+ -- with non-sequential keys becomes a map with stringified keys.
64
+ if isArray and (count == #source or count > MAX_TABLE_ENTRIES) then
65
+ local list: { any } = {}
66
+ for index, item in ipairs(source) do
67
+ if index > MAX_TABLE_ENTRIES or budget.nodes <= 0 or budget.chars < 100 then
68
+ table.insert(list, "<more entries>")
69
+ break
70
+ end
71
+ table.insert(list, describe(item, level + 1, visited, budget))
72
+ end
73
+ -- Cleared on the way out so the same table appearing twice side by side
74
+ -- still renders twice; only a table containing itself is a cycle.
75
+ visited[source] = nil
76
+ return list
77
+ end
78
+
79
+ local map: { [string]: any } = {}
80
+ local shown = 0
81
+ for key, item in source do
82
+ shown += 1
83
+ if shown > MAX_TABLE_ENTRIES or budget.nodes <= 0 or budget.chars < 100 then
84
+ map["..."] = "<more entries>"
85
+ break
86
+ end
87
+ local name = string.sub(tostring(key), 1, 100)
88
+ budget.chars -= #name * 6
89
+ map[name] = describe(item, level + 1, visited, budget)
90
+ end
91
+ visited[source] = nil
92
+ return map
93
+ end
94
+
95
+ local Runtime = {}
96
+ Runtime.describe = describe
97
+ function Runtime.run(chunk: () -> ...any, timeout: number?): { [string]: any }
98
+ -- Captured rather than inferred: a chunk's prints are usually the point, and
99
+ -- reading them back from the log afterwards would also pick up whatever else
100
+ -- the place logged in the meantime.
101
+ local output: { { [string]: any } } = {}
102
+ local outputChars, outputDropped = 0, 0
103
+ local connection = LogService.MessageOut:Connect(function(message, messageType, context)
104
+ if #output < MAX_OUTPUT_LINES and outputChars < 8000 then
105
+ -- A structured log's context rides on the line, as it does in `console`.
106
+ local encoded = LogBuffer.encodeContext(context)
107
+ if encoded then
108
+ message ..= " " .. encoded
109
+ end
110
+ if #message > math.min(2000, 8000 - outputChars) then message = string.sub(message, 1, math.min(2000, 8000 - outputChars)) .. "<truncated>" end
111
+ outputChars += #message
112
+ table.insert(output, {
113
+ message = message,
114
+ level = if messageType == Enum.MessageType.MessageError
115
+ then "error"
116
+ elseif messageType == Enum.MessageType.MessageWarning then "warning"
117
+ elseif messageType == Enum.MessageType.MessageInfo then "info"
118
+ else "print",
119
+ })
120
+ else outputDropped += 1 end
121
+ end)
122
+
123
+ local started = os.clock()
124
+ local results: any = nil
125
+ if timeout then
126
+ local worker = task.spawn(function() results = table.pack(pcall(chunk :: any)) end)
127
+ local deadline = started + timeout
128
+ while results == nil and os.clock() < deadline do task.wait() end
129
+ if results == nil then
130
+ task.cancel(worker)
131
+ results = table.pack(false, "CLIENT_TIMEOUT: execution exceeded " .. tostring(timeout) .. "s")
132
+ end
133
+ else
134
+ results = table.pack(pcall(chunk :: () -> ...any))
135
+ end
136
+ local elapsed = os.clock() - started
137
+
138
+ --[[
139
+ MessageOut is deferred: `print` returns before the event fires, so
140
+ disconnecting straight after the chunk finished captured nothing at all.
141
+ A short settle picks the lines up.
142
+
143
+ It is bounded and stops as soon as the flow stops, rather than waiting a
144
+ fixed period, so a chunk that logged nothing costs almost no time and one
145
+ that logged plenty is not truncated.
146
+ ]]
147
+ local settleDeadline = os.clock() + 0.5
148
+ local seen = #output
149
+ local quietFrames = 0
150
+ while os.clock() < settleDeadline and quietFrames < 3 do
151
+ task.wait()
152
+ if #output == seen then
153
+ quietFrames += 1
154
+ else
155
+ seen = #output
156
+ quietFrames = 0
157
+ end
158
+ end
159
+
160
+ connection:Disconnect()
161
+
162
+ if not results[1] then
163
+ return {
164
+ ok = false,
165
+ error = string.sub(tostring(results[2]), 1, 2000),
166
+ output = output,
167
+ milliseconds = math.floor(elapsed * 1000 + 0.5),
168
+ }
169
+ end
170
+
171
+ local returned: { any } = {}
172
+ local budget: Budget = {nodes = 300, chars = 6000}
173
+ for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
174
+ -- A nil return is written out rather than dropped. `table.insert` of nil
175
+ -- does nothing, so `return a, nil, b` came back as two values and every
176
+ -- one after the nil shifted into the wrong position.
177
+ local value = results[index]
178
+ table.insert(returned, if value == nil then "nil" else describe(value, nil, nil, budget))
179
+ end
180
+
181
+ return {
182
+ ok = true,
183
+ returned = returned,
184
+ outputDropped = if outputDropped > 0 then outputDropped else nil,
185
+ returnsDropped = if results.n > MAX_RETURN_VALUES + 1 then results.n - MAX_RETURN_VALUES - 1 else nil,
186
+ output = output,
187
+ milliseconds = math.floor(elapsed * 1000 + 0.5),
188
+ }
189
+ end
190
+ return Runtime