Files
jasper.nvim/lua/jasper/timer.lua
Paolo Donadeo 4a5acb17be fix(timer): detect remote pause via sentinel value in activity file
When the inactivity watchdog pauses the timer, write `"0"` to the shared
activity file as a sentinel. Other instances read this in
`on_activity()` and update their local `state.running` accordingly,
allowing them to resume the timer without waiting for the pausing
instance.
2026-04-18 19:29:10 +02:00

344 lines
9.6 KiB
Lua

--- jasper/timer.lua
--- Timer state machine + inactivity auto-pause.
local M = {}
local api = require("jasper.api")
local auth = require("jasper.auth")
--- Internal state (one instance per Neovim session).
local state = {
token = nil, ---@type string|nil
timer_id = nil, ---@type number|nil
attivita_id = nil, ---@type number|nil
running = false,
-- inactivity_ms: milliseconds of silence before the timer is auto-paused
inactivity_ms = 10 * 60 * 1000,
-- last epoch (seconds) this instance wrote to the shared activity file
last_activity_time = nil, ---@type number|nil
uv_timer = nil, ---@type uv.uv_timer_t|nil
augroup = nil, ---@type number|nil
}
-- ---------------------------------------------------------------------------
-- Shared-activity timestamp (multi-instance coordination)
-- ---------------------------------------------------------------------------
--- Path of the shared last-activity file for the current attivita_id.
--- @return string|nil
local function activity_file_path()
if not state.attivita_id then
return nil
end
return "/tmp/jasper_" .. tostring(state.attivita_id) .. ".last_activity"
end
--- Write the current epoch (seconds) to the shared activity file.
local function touch_activity_file()
local path = activity_file_path()
if not path then
return
end
local now = os.time()
local f = io.open(path, "w")
if f then
f:write(tostring(now))
f:close()
state.last_activity_time = now
end
end
--- Write 0 to the shared file to signal that the timer has been paused.
--- Other instances will detect this in on_activity() and resume accordingly.
local function mark_timer_paused()
local path = activity_file_path()
if not path then
return
end
local f = io.open(path, "w")
if f then
f:write("0")
f:close()
-- Do NOT update last_activity_time: cleanup logic must keep working correctly
end
end
--- Return true if another instance has recorded activity within `inactivity_ms`.
local function other_instance_active()
local path = activity_file_path()
if not path then
return false
end
local f = io.open(path, "r")
if not f then
return false
end
local raw = f:read("*l")
f:close()
local ts = tonumber(raw)
if not ts then
return false
end
local elapsed_ms = (os.time() - ts) * 1000
return elapsed_ms < state.inactivity_ms
end
--- Remove the shared activity file on exit, but only if this instance was
--- the last to write it. If another instance wrote a newer timestamp, leave
--- the file intact so the other instance's watchdog keeps working correctly.
local function cleanup_activity_file()
local path = activity_file_path()
if not path or not state.last_activity_time then
return
end
local f = io.open(path, "r")
if not f then
return
end
local raw = f:read("*l")
f:close()
local ts = tonumber(raw)
-- Only remove if the timestamp on disk matches what we wrote last
if ts and ts == state.last_activity_time then
os.remove(path)
end
end
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
local function notify(msg, level)
vim.notify("[Jasper] " .. msg, level or vim.log.levels.INFO)
end
--- Stop the uv inactivity countdown (without pausing the Jasper timer).
local function cancel_inactivity_watchdog()
if state.uv_timer then
state.uv_timer:stop()
end
end
--- (Re)start the inactivity countdown.
--- When it fires the Jasper timer is paused.
local function arm_inactivity_watchdog()
if not state.uv_timer or not state.running then
return
end
state.uv_timer:stop()
state.uv_timer:start(
state.inactivity_ms,
0,
vim.schedule_wrap(function()
if not state.running then
return
end
-- Don't pause if another instance with the same attivita_id is still active
if other_instance_active() then
notify("Inactivity detected, but another instance is active — keeping timer running")
arm_inactivity_watchdog()
return
end
local ok, err = api.stop_timer(state.token, state.timer_id)
if ok then
state.running = false
mark_timer_paused() -- signal to other instances that the timer is now paused
notify("Timer paused (inactivity)")
else
notify("Could not pause timer: " .. (err or ""), vim.log.levels.WARN)
end
end)
)
end
--- Find the smallest non-negative integer not already used as a timer_id.
--- @param timers table[]
--- @return number
local function next_free_timer_id(timers)
local used = {}
for _, t in ipairs(timers) do
used[t.timer_id] = true
end
local id = 0
while used[id] do
id = id + 1
end
return id
end
--- Find an existing timer for `attivita_id` or create a new one.
--- @param token string
--- @param attivita_id number
--- @param timers table[]
--- @return number|nil timer_id
--- @return string|nil error
local function find_or_create_timer(token, attivita_id, timers)
for _, t in ipairs(timers) do
if t["attivita__id"] == attivita_id then
return t.timer_id, nil
end
end
-- No existing timer: create one in the smallest free slot
local new_id = next_free_timer_id(timers)
local ok, err = api.create_timer(token, new_id, attivita_id)
if not ok then
return nil, "Cannot create timer: " .. (err or "unknown error")
end
notify("New timer created (ID " .. new_id .. ")")
return new_id, nil
end
-- ---------------------------------------------------------------------------
-- Activity callback (called from autocmds)
-- ---------------------------------------------------------------------------
function M.on_activity()
if not state.timer_id then
return
end
-- Sync local running state with the shared file.
-- If another instance paused the timer it writes "0" as a sentinel.
-- Detect it here so we can resume without waiting for the other instance.
if state.running then
local path = activity_file_path()
if path then
local f = io.open(path, "r")
if f then
local raw = f:read("*l")
f:close()
if tonumber(raw) == 0 then
state.running = false -- remote pause detected; will resume below
end
end
end
end
touch_activity_file()
if not state.running then
-- Resume after an inactivity pause
local ok, err = api.start_timer(state.token, state.timer_id)
if ok then
state.running = true
notify("Timer resumed")
else
notify("Could not resume timer: " .. (err or ""), vim.log.levels.WARN)
return
end
end
arm_inactivity_watchdog()
end
-- ---------------------------------------------------------------------------
-- Lifecycle
-- ---------------------------------------------------------------------------
--- Start tracking: called after token + timer_id are known.
--- @param token string
--- @param timer_id number
local function begin_tracking(token, timer_id)
state.token = token
state.timer_id = timer_id
-- Close any leftover uv_timer from a previous (unexpected) call
if state.uv_timer then
state.uv_timer:stop()
state.uv_timer:close()
state.uv_timer = nil
end
-- Create the libuv timer used for inactivity detection
state.uv_timer = vim.uv.new_timer()
-- Register activity autocmds
state.augroup = vim.api.nvim_create_augroup("JasperActivity", { clear = true })
vim.api.nvim_create_autocmd(
{ "CursorMoved", "CursorMovedI", "InsertCharPre", "BufEnter", "FocusGained" },
{ group = state.augroup, callback = M.on_activity }
)
-- Start the timer immediately
local ok, err = api.start_timer(token, timer_id)
if not ok then
notify("Could not start timer: " .. (err or ""), vim.log.levels.ERROR)
return
end
state.running = true
notify("Timer started (ID " .. timer_id .. ")")
arm_inactivity_watchdog()
end
--- Called after a valid token is obtained (either from cache or fresh login).
--- Fetches timers, creates one if needed, then begins tracking.
--- @param token string
local function on_token_ready(token)
local timers, err = api.get_timers(token)
if err == "unauthorized" then
-- Token is expired: delete it and ask the user to login again
auth.delete_token()
notify("Session expired. Please re-open Neovim to login again.", vim.log.levels.WARN)
return
end
if err then
notify("Cannot fetch timers: " .. err, vim.log.levels.ERROR)
return
end
if not timers then
notify("Cannot fetch timers: empty response", vim.log.levels.ERROR)
return
end
local timer_id, terr = find_or_create_timer(token, state.attivita_id, timers)
if not timer_id then
notify(terr or "Cannot get timer", vim.log.levels.ERROR)
return
end
begin_tracking(token, timer_id)
end
--- Entry point called on VimEnter.
--- @param opts table { attivita_id, inactivity_timeout }
function M.setup(opts)
state.attivita_id = opts.attivita_id
state.inactivity_ms = (opts.inactivity_timeout or 10) * 60 * 1000
local token = auth.get_token()
if token then
on_token_ready(token)
else
auth.prompt_login(function(new_token)
if new_token then
on_token_ready(new_token)
end
end)
end
end
--- Stop tracking: called on VimLeave.
function M.teardown()
cancel_inactivity_watchdog()
if state.uv_timer then
state.uv_timer:close()
state.uv_timer = nil
end
cleanup_activity_file()
if state.running and state.timer_id then
local ok, err = api.stop_timer(state.token, state.timer_id)
if ok then
state.running = false
else
-- Best-effort; we're exiting anyway
vim.notify("[Jasper] Could not stop timer on exit: " .. (err or ""), vim.log.levels.WARN)
end
end
end
return M