@knpkv/jira-clockify 1.1.5 → 1.3.0

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 (40) hide show
  1. package/README.md +9 -2
  2. package/dist/package.json +10 -9
  3. package/dist/src/cli/fetchTicket.js +2 -1
  4. package/dist/src/cli/fetchTicket.js.map +1 -1
  5. package/dist/src/cli/fuzzySelect.js.map +1 -1
  6. package/dist/src/cli/layers.js +18 -0
  7. package/dist/src/cli/layers.js.map +1 -1
  8. package/dist/src/cli/setup.js +1 -1
  9. package/dist/src/cli/setup.js.map +1 -1
  10. package/dist/src/cli/timer/edit.js +8 -8
  11. package/dist/src/cli/timer/edit.js.map +1 -1
  12. package/dist/src/cli/timer/start.js +2 -2
  13. package/dist/src/cli/timer/start.js.map +1 -1
  14. package/dist/src/cli/timer/status.js +117 -66
  15. package/dist/src/cli/timer/status.js.map +1 -1
  16. package/dist/src/cli/timer/stop.js +1 -1
  17. package/dist/src/cli/timer/stop.js.map +1 -1
  18. package/dist/src/services/ClockifyAuth.js.map +1 -1
  19. package/dist/src/services/ConfigService.js +11 -15
  20. package/dist/src/services/ConfigService.js.map +1 -1
  21. package/dist/src/services/HomeDirectory.js.map +1 -1
  22. package/dist/src/services/ReconcileService.js +6 -6
  23. package/dist/src/services/ReconcileService.js.map +1 -1
  24. package/dist/src/services/StateWriter.js +8 -8
  25. package/dist/src/services/StateWriter.js.map +1 -1
  26. package/dist/src/services/TicketService.js +13 -10
  27. package/dist/src/services/TicketService.js.map +1 -1
  28. package/dist/src/services/TimerService.js +20 -22
  29. package/dist/src/services/TimerService.js.map +1 -1
  30. package/dist/src/tui/App.js +1 -1
  31. package/dist/src/tui/App.js.map +1 -1
  32. package/dist/src/tui/components/BigTimer.js +0 -1
  33. package/dist/src/tui/components/BigTimer.js.map +1 -1
  34. package/dist/src/tui/components/PopupInput.js +2 -1
  35. package/dist/src/tui/components/PopupInput.js.map +1 -1
  36. package/dist/src/tui/components/TicketRow.js +1 -1
  37. package/dist/src/tui/components/TicketRow.js.map +1 -1
  38. package/dist/tsconfig.tsbuildinfo +1 -1
  39. package/nvim/lua/jcf/state.lua +328 -12
  40. package/package.json +14 -13
@@ -1,19 +1,53 @@
1
1
  local M = {}
2
2
  local uv = vim.loop or vim.uv
3
3
  local cached = { active = false }
4
- local last_mtime = 0
4
+ local last_key = nil
5
+ local last_path = nil
5
6
  local poll_timer = nil
7
+ local poll_job = nil
8
+ -- Config captured by `start_poll`, so the command and its lock/stamp paths stay
9
+ -- stable for the lifetime of the timer.
10
+ local poll_config = nil
11
+ -- The interval `start_poll` was given. Needed away from that function because it
12
+ -- is also the machine's poll budget, not just this editor's tick spacing.
13
+ local poll_interval_ms = nil
14
+ local poll_once
15
+ local arm_poll
16
+
17
+ -- The cache key has to change on every write. `mtime.sec` alone does not: a
18
+ -- write landing in the same filesystem second as the previous read is
19
+ -- invisible, so starting a timer right after a statusline redraw can leave
20
+ -- `cached.active` false indefinitely. That used to be a cosmetic lag, but the
21
+ -- poll below is now gated on `.active` — a stale "inactive" reading suppresses
22
+ -- the very poll that would refresh the file, and nothing recovers it. Include
23
+ -- sub-second mtime and size so same-second writes are still observed. Aliasing
24
+ -- would need whole-second mtimes *and* an identical file size across the
25
+ -- inactive->active transition, which the two JSON payloads never have. If it
26
+ -- somehow happened, nothing periodic would clear it — no jcf process rewrites
27
+ -- this file on a schedule — so recovery would wait for the user's next jcf
28
+ -- command.
29
+ local function stat_key(stat)
30
+ return string.format("%d.%09d:%d", stat.mtime.sec, stat.mtime.nsec or 0, stat.size or 0)
31
+ end
6
32
 
7
33
  function M.read(state_path)
8
34
  local path = state_path or vim.fn.expand("~/.jcf/state.json")
9
35
  local stat = uv.fs_stat(path)
10
36
  if not stat then
37
+ -- The file is gone, so whatever we cached describes a world that no longer
38
+ -- exists. Handing back a stale `active` reading would keep the poll below
39
+ -- spawning `jcf timer status` every tick against a timer that isn't there.
40
+ cached = { active = false }
41
+ last_key = nil
42
+ last_path = nil
11
43
  return cached
12
44
  end
13
- if stat.mtime.sec == last_mtime then
45
+ local key = stat_key(stat)
46
+ if path == last_path and key == last_key then
14
47
  return cached
15
48
  end
16
- last_mtime = stat.mtime.sec
49
+ last_path = path
50
+ last_key = key
17
51
  local f = io.open(path, "r")
18
52
  if f then
19
53
  local content = f:read("*a")
@@ -27,22 +61,291 @@ function M.read(state_path)
27
61
  end
28
62
 
29
63
  -- Periodically run `jcf timer status` to sync state file with Clockify
30
- -- This detects externally stopped timers
64
+ -- This detects externally stopped timers.
65
+ --
66
+ -- The poll must never stack. `jcf timer status` does network I/O and can hang
67
+ -- indefinitely, and one nvim runs per project, so every leaked process is
68
+ -- multiplied by the number of open editors. Hence: at most one job in flight,
69
+ -- owned by nvim (never detached, so VimLeave and jobstop can actually kill it),
70
+ -- under a watchdog. When no timer is running locally there is nothing to
71
+ -- reconcile, so the spawn is skipped entirely. That bounds one editor to one
72
+ -- poll; the lock below bounds the whole machine to one, which is the part that
73
+ -- actually decides the cost when many editors are open.
74
+ -- Backstop only. `jcf timer status` bounds every network call it can make, but
75
+ -- not all at the same value: an expired Jira token costs up to
76
+ -- `REFRESH_TIMEOUT` (30s, in @knpkv/jira-cli's JiraAuth) before any command
77
+ -- body runs, then the four Clockify calls cost up to `API_TIMEOUT` (10s each,
78
+ -- in src/cli/timer/status.ts). So ~70s of bounded work plus process startup.
79
+ -- The watchdog sits well above that — set near it, nvim would be the thing
80
+ -- killing a merely slow but healthy poll, and a kill landing inside the state
81
+ -- file's write-then-rename leaves a stray `state.json.tmp`. Recompute this
82
+ -- from those two constants by name if either changes.
83
+ local POLL_TIMEOUT_MS = 120000
84
+ -- How long our own SIGTERM gets before we escalate. We signal the pid directly
85
+ -- rather than via `jobstop`, because `jobstop` starts nvim's own kill timer and
86
+ -- escalates to SIGKILL after about two seconds — far too short for an in-flight
87
+ -- OAuth rotation, and not a window we can lengthen.
88
+ --
89
+ -- Must exceed `REFRESH_TIMEOUT` (30s, in @knpkv/jira-cli's JiraAuth), or we
90
+ -- escalate while the very rotation this grace exists for is still legitimately
91
+ -- running.
92
+ --
93
+ -- This is a courtesy, not a guarantee. On `VimLeave` nvim exits immediately and
94
+ -- hard-kills surviving jobs, so no grace elapses at all there — a cost of
95
+ -- owning the job rather than detaching it, which is what makes it reapable at
96
+ -- all. The CLI narrows the damage: it refuses to discard a stored token unless
97
+ -- Atlassian explicitly said the grant was invalid, so an interrupted refresh
98
+ -- normally costs a retry rather than the session. That is narrowing, not
99
+ -- immunity — a hard kill after Atlassian has already rotated the token still
100
+ -- loses the replacement, and the next refresh then legitimately reports
101
+ -- `invalid_grant`. No client-side design can close that window.
102
+ local POLL_STOP_GRACE_MS = 35000
103
+
104
+ -- ---------------------------------------------------------------------------
105
+ -- Cross-editor lock: one `jcf timer status` per machine, not per editor.
106
+ -- ---------------------------------------------------------------------------
107
+ --
108
+ -- The single-flight guard above is process-local. It bounds one nvim to one
109
+ -- poll and says nothing about the others, so with an editor open per project it
110
+ -- does not bound anything that matters: 19 editors on a 30s timer is 19 cold
111
+ -- Node starts every 30s, each making the same four Clockify calls to reconcile
112
+ -- the same single global timer.
113
+ --
114
+ -- The work is inherently shared — one `~/.jcf/state.json`, one running timer —
115
+ -- so exactly one editor should do it and the rest should read the file it
116
+ -- refreshes, which `M.read` already does off an mtime cache. Coordination uses
117
+ -- util-linux `flock`: every editor starts a non-blocking contender, the kernel
118
+ -- lets exactly one exec `jcf`, and every loser exits without starting Node.
119
+ -- `--no-fork` leaves the lock attached to the `jcf` process itself. If nvim is
120
+ -- SIGKILLed, its child therefore keeps the lock until the poll really exits.
121
+ --
122
+ -- The lock is half of it. It bounds how many polls run *at once*, and because
123
+ -- it is released as soon as the CLI exits, it says nothing about how often they
124
+ -- run — 19 editors would still spawn ~19 times per interval, just never two at
125
+ -- the same moment. The poll stamp below is what bounds the rate, and the two
126
+ -- together are what make the "per machine" claim above true.
127
+ --
128
+ -- No directory is created for it. The lock sits beside the CLI's fixed state
129
+ -- authority. Neovim's configurable `state_path` only changes which file the UI
130
+ -- reads; `jcf timer status` still mutates `~/.jcf/state.json`, so every editor
131
+ -- must coordinate on that one path regardless of its display configuration.
132
+ local function cli_state_path()
133
+ return vim.fn.expand("~/.jcf/state.json")
134
+ end
135
+
136
+ local function lock_path()
137
+ return vim.fn.fnamemodify(cli_state_path(), ":h") .. "/poll.lock"
138
+ end
139
+
140
+ -- ---------------------------------------------------------------------------
141
+ -- Poll stamp: the lock bounds overlap, this bounds rate.
142
+ -- ---------------------------------------------------------------------------
143
+ --
144
+ -- The lock is held only while `jcf timer status` runs — a second or two — and
145
+ -- released the moment it exits. That makes two polls never overlap, which is not
146
+ -- the same as making them rare: 19 editors on a 30s timer tick every ~1.6s
147
+ -- between them, and by then the lock is free again, so nearly every tick would
148
+ -- still spawn. The lock alone therefore delivers ~19 polls per interval, the
149
+ -- number it exists to avoid.
150
+ --
151
+ -- What actually bounds the machine is a record that survives the release. Each
152
+ -- finished attempt writes a stamp file before releasing the lock, and a tick
153
+ -- that finds the stamp younger than one interval skips. Failed attempts count:
154
+ -- otherwise every de-phased editor would retry the same persistent failure in
155
+ -- turn. Readers keep the last state file until the next attempt. That is the
156
+ -- whole rate limit, and it is what lets the editors stay de-phased without
157
+ -- multiplying the work.
158
+ --
159
+ -- The CLI owns the write so no successor can acquire the lock between process
160
+ -- exit and the stamp becoming visible.
161
+ local function stamp_path()
162
+ return vim.fn.fnamemodify(cli_state_path(), ":h") .. "/poll.stamp"
163
+ end
164
+
165
+ local function wall_clock_ms()
166
+ local sec, usec = uv.gettimeofday()
167
+ return (sec * 1000) + math.floor(usec / 1000)
168
+ end
169
+
170
+ -- A watchdog escalation bypasses the CLI's Effect finalizers. Write the failed
171
+ -- attempt synchronously while the child still owns `poll.lock`, so another
172
+ -- editor cannot acquire the lock between SIGKILL and the shared rate bound.
173
+ local function stamp_failed_attempt()
174
+ local fd = uv.fs_open(stamp_path(), "w", 384) -- 0600
175
+ if not fd then
176
+ return
177
+ end
178
+ uv.fs_write(fd, tostring(wall_clock_ms()), -1)
179
+ uv.fs_close(fd)
180
+ end
181
+
182
+ -- Whether some editor on this machine already reconciled within the interval.
183
+ -- Read from the stamp's own mtime, so a torn or truncated write still carries a
184
+ -- usable time and no parsing can fail here.
185
+ local function polled_recently()
186
+ if not poll_interval_ms then
187
+ return false
188
+ end
189
+ local stat = uv.fs_stat(stamp_path())
190
+ if not stat then
191
+ return false
192
+ end
193
+ local stamped_at_ms = (stat.mtime.sec * 1000) + math.floor((stat.mtime.nsec or 0) / 1000000)
194
+ local age_ms = wall_clock_ms() - stamped_at_ms
195
+ -- A stamp dated in the future is a clock that moved backwards, not a poll that
196
+ -- has not happened yet; treating it as recent would wedge polling until the
197
+ -- clock caught up.
198
+ if age_ms < 0 then
199
+ return false
200
+ end
201
+ return age_ms < poll_interval_ms
202
+ end
203
+
204
+ local function own_pid()
205
+ if uv.os_getpid then
206
+ return uv.os_getpid()
207
+ end
208
+ return vim.fn.getpid()
209
+ end
210
+
211
+ -- One-shot scheduling keeps this editor's cadence anchored to completion, not
212
+ -- start. A repeating timer would tick one interval after start, see the fresh
213
+ -- completion stamp, skip, and wait a second whole interval before trying again.
214
+ arm_poll = function(delay_ms)
215
+ if not poll_timer or not poll_config then
216
+ return
217
+ end
218
+ poll_timer:stop()
219
+ poll_timer:start(delay_ms, 0, vim.schedule_wrap(function()
220
+ poll_once(poll_config)
221
+ end))
222
+ end
223
+
224
+ -- Ask `job` to stop, then escalate if it does not. Signals the pid directly
225
+ -- rather than calling `jobstop`, which starts nvim's own ~2s kill timer and
226
+ -- would SIGKILL the CLI long before an in-flight OAuth rotation could finish.
227
+ --
228
+ -- `flock --no-fork` execs the CLI, so the pid we signal is also the process
229
+ -- holding the kernel lock. The lock cannot be released before that process is
230
+ -- dead.
231
+ local function terminate(job)
232
+ local signalled, pid = pcall(vim.fn.jobpid, job)
233
+ if signalled and pid > 0 then
234
+ uv.kill(pid, "sigterm")
235
+ else
236
+ stamp_failed_attempt()
237
+ vim.fn.jobstop(job) -- no pid to signal; fall back to nvim's own teardown
238
+ end
239
+
240
+ vim.defer_fn(function()
241
+ if poll_job ~= job then
242
+ return
243
+ end
244
+ if vim.fn.jobwait({ job }, 0)[1] ~= -1 then
245
+ poll_job = nil -- already exited without `on_exit` reaching us
246
+ arm_poll(poll_interval_ms)
247
+ return
248
+ end
249
+ -- SIGTERM was not enough. Escalate, and let `on_exit` do the releasing.
250
+ local ok, live_pid = pcall(vim.fn.jobpid, job)
251
+ if ok and live_pid > 0 then
252
+ stamp_failed_attempt()
253
+ uv.kill(live_pid, "sigkill")
254
+ return
255
+ end
256
+ -- No pid to escalate against, so nothing further will make this job report.
257
+ -- `jobstop` once more, then release on the next proof rather than wedging
258
+ -- the poll for the rest of the session.
259
+ stamp_failed_attempt()
260
+ vim.fn.jobstop(job)
261
+ vim.defer_fn(function()
262
+ if poll_job == job and vim.fn.jobwait({ job }, 0)[1] ~= -1 then
263
+ poll_job = nil
264
+ arm_poll(poll_interval_ms)
265
+ end
266
+ end, POLL_STOP_GRACE_MS)
267
+ end, POLL_STOP_GRACE_MS)
268
+ end
269
+
270
+ poll_once = function(config)
271
+ if poll_job then
272
+ arm_poll(poll_interval_ms)
273
+ return -- previous poll still in flight; do not stack
274
+ end
275
+ if not M.read(cli_state_path()).active then
276
+ arm_poll(poll_interval_ms)
277
+ return -- no local timer, nothing to reconcile
278
+ end
279
+ if polled_recently() then
280
+ arm_poll(poll_interval_ms)
281
+ return -- someone already reconciled this interval; read their result instead
282
+ end
283
+
284
+ -- `job` is declared first so on_exit closes over it, not a global.
285
+ local job
286
+ job = vim.fn.jobstart({
287
+ "flock",
288
+ "--no-fork",
289
+ "--nonblock",
290
+ "--conflict-exit-code",
291
+ "75",
292
+ lock_path(),
293
+ config.binary or "jcf",
294
+ "timer",
295
+ "status",
296
+ "--nvim-poll-stamp",
297
+ stamp_path(),
298
+ "--nvim-poll-interval-ms",
299
+ tostring(poll_interval_ms),
300
+ }, {
301
+ on_stdout = function() end,
302
+ on_stderr = function() end,
303
+ on_exit = function()
304
+ if poll_job == job then
305
+ poll_job = nil
306
+ arm_poll(poll_interval_ms)
307
+ end
308
+ end,
309
+ })
310
+ if job <= 0 then
311
+ arm_poll(poll_interval_ms)
312
+ return -- failed to spawn (`flock` missing); try again next tick
313
+ end
314
+ poll_job = job
315
+
316
+ vim.defer_fn(function()
317
+ if poll_job ~= job then
318
+ return
319
+ end
320
+ terminate(job)
321
+ end, POLL_TIMEOUT_MS)
322
+ end
323
+
31
324
  function M.start_poll(config, interval_ms)
32
325
  if poll_timer then
33
326
  return
34
327
  end
35
328
  interval_ms = interval_ms or 30000 -- 30s default
329
+ if type(interval_ms) ~= "number" or interval_ms <= 0 or interval_ms % 1 ~= 0 then
330
+ return -- non-positive and non-integral values disable polling
331
+ end
332
+ poll_config = config
333
+ poll_interval_ms = interval_ms
334
+
335
+ -- Spread the first tick across a whole interval, keyed off the pid, so editors
336
+ -- opened in a batch (a session restore, a fleet of worktrees) do not line up on
337
+ -- the same millisecond forever after. Derived from the pid rather than
338
+ -- `math.random`, which is unseeded per process and would hand every editor the
339
+ -- same offset.
340
+ --
341
+ -- De-phasing is only safe because the stamp bounds the rate. Spreading the
342
+ -- ticks removes the collisions the lock would otherwise resolve, so with the
343
+ -- lock alone this would make things worse, not better: every tick would find
344
+ -- a free lock and spawn.
345
+ local first_ms = own_pid() % interval_ms
36
346
 
37
347
  poll_timer = uv.new_timer()
38
- poll_timer:start(interval_ms, interval_ms, vim.schedule_wrap(function()
39
- -- jcf timer status updates ~/.jcf/state.json and detects external stops
40
- vim.fn.jobstart({ config.binary or "jcf", "timer", "status" }, {
41
- on_stdout = function() end,
42
- on_stderr = function() end,
43
- detach = true,
44
- })
45
- end))
348
+ arm_poll(first_ms)
46
349
  end
47
350
 
48
351
  function M.stop_poll()
@@ -51,6 +354,19 @@ function M.stop_poll()
51
354
  poll_timer:close()
52
355
  poll_timer = nil
53
356
  end
357
+ if not poll_job then
358
+ return
359
+ end
360
+ if poll_job then
361
+ -- Exactly the watchdog's sequence, for the same reason: `jobstop` here
362
+ -- would hand the CLI nvim's ~2s kill timer and could SIGKILL it between the
363
+ -- OAuth grant and the rotated token being persisted. Teardown and
364
+ -- reconfiguration deserve the same grace as a timeout. (On `VimLeave` nvim
365
+ -- exits before any of it elapses — the CLI's own retry-not-delete rule is
366
+ -- what covers that case.)
367
+ stamp_failed_attempt()
368
+ terminate(poll_job)
369
+ end
54
370
  end
55
371
 
56
372
  return M
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knpkv/jira-clockify",
3
- "version": "1.1.5",
3
+ "version": "1.3.0",
4
4
  "description": "TUI for Jira-Clockify time tracking, attachable to neovim",
5
5
  "license": "MIT",
6
6
  "author": "knpkv",
@@ -14,26 +14,27 @@
14
14
  "files": [
15
15
  "dist",
16
16
  "nvim",
17
+ "!nvim/test",
17
18
  "skills"
18
19
  ],
19
20
  "dependencies": {
20
- "@effect/atom-react": "4.0.0-beta.98",
21
- "@effect/platform-node": "4.0.0-beta.98",
21
+ "@effect/atom-react": "4.0.0-rc.109",
22
+ "@effect/platform-node": "4.0.0-rc.109",
22
23
  "@opentui/core": "0.5.1",
23
24
  "@opentui/react": "0.5.1",
24
- "effect": "4.0.0-beta.98",
25
- "react": "^19.2.7",
26
- "@knpkv/agent-skills": "^0.2.3",
27
- "@knpkv/atlassian-common": "^1.3.0",
28
- "@knpkv/clockify-api-client": "^1.0.3",
29
- "@knpkv/jira-api-client": "^1.0.1",
30
- "@knpkv/jira-cli": "^1.2.3"
25
+ "effect": "4.0.0-rc.109",
26
+ "react": "^19.2.8",
27
+ "@knpkv/agent-skills": "^0.3.1",
28
+ "@knpkv/atlassian-common": "^1.4.1",
29
+ "@knpkv/clockify-api-client": "^1.1.1",
30
+ "@knpkv/jira-api-client": "^1.1.1",
31
+ "@knpkv/jira-cli": "^1.3.1"
31
32
  },
32
33
  "devDependencies": {
33
- "@effect/vitest": "4.0.0-beta.98",
34
+ "@effect/vitest": "4.0.0-rc.109",
34
35
  "@types/node": "latest",
35
- "@types/react": "^19.2.17",
36
- "tsx": "^4.23.0",
36
+ "@types/react": "^19.2.18",
37
+ "tsx": "^4.23.12",
37
38
  "vitest": "^4.1.10"
38
39
  },
39
40
  "scripts": {