Remote Control

@rebindv0.6.0

Connect your programs to Rebind over WebSocket using the documented remote-control server. Works with the client libraries and MCP.

Files

observe.luau

--[[ observe.* commands

  rb.observe over the wire. main.luau registers each command behind the
  allow_observe gate; the ones that wait for the recorder are async, so the
  dispatcher runs them inside rb.run.

  this script owns every session it starts, whichever connection asked, so it
  remembers that connection: only it may mark or stop the session, and the
  session stops when it disconnects. while a start waits on the recorder, no
  other connection may mark or stop. a session that ends on its own (time
  limit, stop key) is forgotten once status shows it gone. a session also
  ends when this script stops (reason script_stopped), so OnStop does nothing
  here.

  the recording's id travels in `session`, since `id` is the request's
  correlation id. a status carries its own `id`, so start, stop and status
  reply with it under `session`.
]]

local M = {}

-- the session this script started and the connection that asked for it
local started: { id: string, client: number }? = nil
-- connection id -> starts of its still waiting on the recorder
local starting: { [number]: number } = {}

local function unavailable()
  return nil, "unavailable", "observation needs Rebind 3.15.0 or newer"
end

-- forget a session that ended on its own
local function forget_ended()
  if started == nil then
    return
  end
  local now = rb.observe.status()
  if now == nil or now.id ~= started.id then
    started = nil
  end
end

-- (code, message) when another connection's session is running or starting
local function not_owner(client)
  for id in starting do
    if id ~= client.id then
      return "not_owner", "another connection is starting an observation"
    end
  end
  forget_ended()
  if started ~= nil and started.client ~= client.id then
    return "not_owner", "another connection started this observation"
  end
  return nil
end

-- (code, message) when a request lacks the recording id
local function no_session(command, req)
  if type(req.session) ~= "string" then
    return "handler_error", `{command}: session must be a string`
  end
  return nil
end

-- `connected(client_id)` says whether a connection is still open
function M.commands(connected: (number) -> boolean)
  local commands = {}

  commands["observe.start"] = {
    async = true,
    fn = function(client, req)
      if rb.observe == nil then
        return unavailable()
      end
      starting[client.id] = (starting[client.id] or 0) + 1
      local ok, status = pcall(rb.observe.start, {
        seconds = req.seconds,
        stop_key = req.stop_key,
        goal = req.goal,
        frame_ms = req.frame_ms,
        frame_edge = req.frame_edge,
      })
      local left = starting[client.id] - 1
      starting[client.id] = if left > 0 then left else nil
      if not ok then
        error(status, 0)
      end
      started = { id = status.id, client = client.id }
      -- the connection closed while the recorder started
      if not connected(client.id) then
        started = nil
        pcall(rb.observe.stop)
      end
      return { session = status }
    end,
  }

  commands["observe.mark"] = {
    fn = function(client, req)
      if rb.observe == nil then
        return unavailable()
      end
      local code, message = not_owner(client)
      if code then
        return nil, code, message
      end
      return {
        queued = rb.observe.mark(req.kind or "mark", req.label, req.data),
      }
    end,
  }

  commands["observe.stop"] = {
    async = true,
    fn = function(client)
      if rb.observe == nil then
        return unavailable()
      end
      local code, message = not_owner(client)
      if code then
        return nil, code, message
      end
      -- a session that ended on its own answers with its final status
      local ok, status = pcall(rb.observe.stop)
      started = nil
      if not ok then
        error(status, 0)
      end
      return { session = status }
    end,
  }

  commands["observe.status"] = {
    fn = function()
      if rb.observe == nil then
        return unavailable()
      end
      forget_ended()
      local status = rb.observe.status()
      return { running = status ~= nil, session = status }
    end,
  }

  commands["observe.list"] = {
    async = true,
    fn = function()
      if rb.observe == nil then
        return unavailable()
      end
      return { recordings = rb.observe.list() }
    end,
  }

  commands["observe.events"] = {
    async = true,
    fn = function(_, req)
      if rb.observe == nil then
        return unavailable()
      end
      local code, message = no_session("observe.events", req)
      if code then
        return nil, code, message
      end
      return rb.observe.events(
        req.session,
        req.from_seq,
        req.to_seq,
        req.max_bytes
      )
    end,
  }

  commands["observe.frame"] = {
    async = true,
    fn = function(_, req)
      if rb.observe == nil then
        return unavailable()
      end
      local code, message = no_session("observe.frame", req)
      if code then
        return nil, code, message
      end
      return rb.observe.frame(req.session, req.file, req.max_edge)
    end,
  }

  return commands
end

-- a connection closed: stop the session it started, if one is running. stop
-- waits for the recorder, so it runs in a coroutine; a session that already
-- ended refuses the stop, and that refusal is dropped.
function M.closed(client_id: number)
  if started == nil or started.client ~= client_id or rb.observe == nil then
    return
  end
  started = nil
  rb.run(function()
    pcall(rb.observe.stop)
  end)
end

return M