- Extract JASPER_URL into a shared constants module - Pass login credentials via a temp curl config file to avoid exposure in the process argument list (ps/proc) - Replace vim.ui.input secret prompt with vim.fn.inputsecret() - Add -s (silent) flag to all curl calls to suppress progress output - Guard curl output parser against missing newline in stdout - Track per-activity shared timestamp file (/tmp/jasper_<id>.last_activity) so the inactivity watchdog skips auto-pause when another Neovim instance is still active on the same task - Clean up leftover uv_timer on repeated begin_tracking calls - Remove shared activity file on teardown only when this instance wrote it
310 lines
8.6 KiB
Lua
310 lines
8.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
|
|
|
|
--- 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
|
|
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
|
|
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
|