@knpkv/jira-clockify 1.1.5 → 1.2.1

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 (39) hide show
  1. package/dist/package.json +10 -9
  2. package/dist/src/cli/fetchTicket.js +2 -1
  3. package/dist/src/cli/fetchTicket.js.map +1 -1
  4. package/dist/src/cli/fuzzySelect.js.map +1 -1
  5. package/dist/src/cli/layers.js +18 -0
  6. package/dist/src/cli/layers.js.map +1 -1
  7. package/dist/src/cli/setup.js +1 -1
  8. package/dist/src/cli/setup.js.map +1 -1
  9. package/dist/src/cli/timer/edit.js +8 -8
  10. package/dist/src/cli/timer/edit.js.map +1 -1
  11. package/dist/src/cli/timer/start.js +2 -2
  12. package/dist/src/cli/timer/start.js.map +1 -1
  13. package/dist/src/cli/timer/status.js +16 -4
  14. package/dist/src/cli/timer/status.js.map +1 -1
  15. package/dist/src/cli/timer/stop.js +1 -1
  16. package/dist/src/cli/timer/stop.js.map +1 -1
  17. package/dist/src/services/ClockifyAuth.js.map +1 -1
  18. package/dist/src/services/ConfigService.js +11 -15
  19. package/dist/src/services/ConfigService.js.map +1 -1
  20. package/dist/src/services/HomeDirectory.js.map +1 -1
  21. package/dist/src/services/ReconcileService.js +6 -6
  22. package/dist/src/services/ReconcileService.js.map +1 -1
  23. package/dist/src/services/StateWriter.js +8 -8
  24. package/dist/src/services/StateWriter.js.map +1 -1
  25. package/dist/src/services/TicketService.js +13 -10
  26. package/dist/src/services/TicketService.js.map +1 -1
  27. package/dist/src/services/TimerService.js +20 -22
  28. package/dist/src/services/TimerService.js.map +1 -1
  29. package/dist/src/tui/App.js +1 -1
  30. package/dist/src/tui/App.js.map +1 -1
  31. package/dist/src/tui/components/BigTimer.js +0 -1
  32. package/dist/src/tui/components/BigTimer.js.map +1 -1
  33. package/dist/src/tui/components/PopupInput.js +2 -1
  34. package/dist/src/tui/components/PopupInput.js.map +1 -1
  35. package/dist/src/tui/components/TicketRow.js +1 -1
  36. package/dist/src/tui/components/TicketRow.js.map +1 -1
  37. package/dist/tsconfig.tsbuildinfo +1 -1
  38. package/nvim/lua/jcf/state.lua +148 -10
  39. package/package.json +14 -13
@@ -1,19 +1,42 @@
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
5
  local poll_timer = nil
6
+ local poll_job = nil
7
+
8
+ -- The cache key has to change on every write. `mtime.sec` alone does not: a
9
+ -- write landing in the same filesystem second as the previous read is
10
+ -- invisible, so starting a timer right after a statusline redraw can leave
11
+ -- `cached.active` false indefinitely. That used to be a cosmetic lag, but the
12
+ -- poll below is now gated on `.active` — a stale "inactive" reading suppresses
13
+ -- the very poll that would refresh the file, and nothing recovers it. Include
14
+ -- sub-second mtime and size so same-second writes are still observed. Aliasing
15
+ -- would need whole-second mtimes *and* an identical file size across the
16
+ -- inactive->active transition, which the two JSON payloads never have. If it
17
+ -- somehow happened, nothing periodic would clear it — no jcf process rewrites
18
+ -- this file on a schedule — so recovery would wait for the user's next jcf
19
+ -- command.
20
+ local function stat_key(stat)
21
+ return string.format("%d.%09d:%d", stat.mtime.sec, stat.mtime.nsec or 0, stat.size or 0)
22
+ end
6
23
 
7
24
  function M.read(state_path)
8
25
  local path = state_path or vim.fn.expand("~/.jcf/state.json")
9
26
  local stat = uv.fs_stat(path)
10
27
  if not stat then
28
+ -- The file is gone, so whatever we cached describes a world that no longer
29
+ -- exists. Handing back a stale `active` reading would keep the poll below
30
+ -- spawning `jcf timer status` every tick against a timer that isn't there.
31
+ cached = { active = false }
32
+ last_key = nil
11
33
  return cached
12
34
  end
13
- if stat.mtime.sec == last_mtime then
35
+ local key = stat_key(stat)
36
+ if key == last_key then
14
37
  return cached
15
38
  end
16
- last_mtime = stat.mtime.sec
39
+ last_key = key
17
40
  local f = io.open(path, "r")
18
41
  if f then
19
42
  local content = f:read("*a")
@@ -27,7 +50,118 @@ function M.read(state_path)
27
50
  end
28
51
 
29
52
  -- Periodically run `jcf timer status` to sync state file with Clockify
30
- -- This detects externally stopped timers
53
+ -- This detects externally stopped timers.
54
+ --
55
+ -- The poll must never stack. `jcf timer status` does network I/O and can hang
56
+ -- indefinitely, and one nvim runs per project, so every leaked process is
57
+ -- multiplied by the number of open editors. Hence: at most one job in flight,
58
+ -- owned by nvim (never detached, so VimLeave and jobstop can actually kill it),
59
+ -- under a watchdog. When no timer is running locally there is nothing to
60
+ -- reconcile, so the spawn is skipped entirely.
61
+ -- Backstop only. `jcf timer status` bounds every network call it can make, but
62
+ -- not all at the same value: an expired Jira token costs up to
63
+ -- `REFRESH_TIMEOUT` (30s, in @knpkv/jira-cli's JiraAuth) before any command
64
+ -- body runs, then the four Clockify calls cost up to `API_TIMEOUT` (10s each,
65
+ -- in src/cli/timer/status.ts). So ~70s of bounded work plus process startup.
66
+ -- The watchdog sits well above that — set near it, nvim would be the thing
67
+ -- killing a merely slow but healthy poll, and a kill landing inside the state
68
+ -- file's write-then-rename leaves a stray `state.json.tmp`. Recompute this
69
+ -- from those two constants by name if either changes.
70
+ local POLL_TIMEOUT_MS = 120000
71
+ -- How long our own SIGTERM gets before we escalate. We signal the pid directly
72
+ -- rather than via `jobstop`, because `jobstop` starts nvim's own kill timer and
73
+ -- escalates to SIGKILL after about two seconds — far too short for an in-flight
74
+ -- OAuth rotation, and not a window we can lengthen.
75
+ --
76
+ -- Must exceed `REFRESH_TIMEOUT` (30s, in @knpkv/jira-cli's JiraAuth), or we
77
+ -- escalate while the very rotation this grace exists for is still legitimately
78
+ -- running.
79
+ --
80
+ -- This is a courtesy, not a guarantee. On `VimLeave` nvim exits immediately and
81
+ -- hard-kills surviving jobs, so no grace elapses at all there — a cost of
82
+ -- owning the job rather than detaching it, which is what makes it reapable at
83
+ -- all. The CLI narrows the damage: it refuses to discard a stored token unless
84
+ -- Atlassian explicitly said the grant was invalid, so an interrupted refresh
85
+ -- normally costs a retry rather than the session. That is narrowing, not
86
+ -- immunity — a hard kill after Atlassian has already rotated the token still
87
+ -- loses the replacement, and the next refresh then legitimately reports
88
+ -- `invalid_grant`. No client-side design can close that window.
89
+ local POLL_STOP_GRACE_MS = 35000
90
+
91
+ -- Ask `job` to stop, then escalate if it does not. Signals the pid directly
92
+ -- rather than calling `jobstop`, which starts nvim's own ~2s kill timer and
93
+ -- would SIGKILL the CLI long before an in-flight OAuth rotation could finish.
94
+ --
95
+ -- The guard is never released here: only proof of death releases it, because
96
+ -- letting a tick spawn a second poll beside a dying one is the stacking leak
97
+ -- this whole mechanism exists to prevent. When in doubt we keep holding, at the
98
+ -- cost of no further polling until nvim restarts.
99
+ local function terminate(job)
100
+ local signalled, pid = pcall(vim.fn.jobpid, job)
101
+ if signalled and pid > 0 then
102
+ uv.kill(pid, "sigterm")
103
+ else
104
+ vim.fn.jobstop(job) -- no pid to signal; fall back to nvim's own teardown
105
+ end
106
+
107
+ vim.defer_fn(function()
108
+ if poll_job ~= job then
109
+ return
110
+ end
111
+ if vim.fn.jobwait({ job }, 0)[1] ~= -1 then
112
+ poll_job = nil -- already exited without `on_exit` reaching us
113
+ return
114
+ end
115
+ -- SIGTERM was not enough. Escalate, and let `on_exit` do the releasing.
116
+ local ok, live_pid = pcall(vim.fn.jobpid, job)
117
+ if ok and live_pid > 0 then
118
+ uv.kill(live_pid, "sigkill")
119
+ return
120
+ end
121
+ -- No pid to escalate against, so nothing further will make this job report.
122
+ -- `jobstop` once more, then release on the next proof rather than wedging
123
+ -- the poll for the rest of the session.
124
+ vim.fn.jobstop(job)
125
+ vim.defer_fn(function()
126
+ if poll_job == job and vim.fn.jobwait({ job }, 0)[1] ~= -1 then
127
+ poll_job = nil
128
+ end
129
+ end, POLL_STOP_GRACE_MS)
130
+ end, POLL_STOP_GRACE_MS)
131
+ end
132
+
133
+ local function poll_once(config)
134
+ if poll_job then
135
+ return -- previous poll still in flight; do not stack
136
+ end
137
+ if not M.read(config.state_path).active then
138
+ return -- no local timer, nothing to reconcile
139
+ end
140
+
141
+ -- `job` is declared first so on_exit closes over it, not a global.
142
+ local job
143
+ job = vim.fn.jobstart({ config.binary or "jcf", "timer", "status" }, {
144
+ on_stdout = function() end,
145
+ on_stderr = function() end,
146
+ on_exit = function()
147
+ if poll_job == job then
148
+ poll_job = nil
149
+ end
150
+ end,
151
+ })
152
+ if job <= 0 then
153
+ return -- failed to spawn (missing binary); try again next tick
154
+ end
155
+ poll_job = job
156
+
157
+ vim.defer_fn(function()
158
+ if poll_job ~= job then
159
+ return
160
+ end
161
+ terminate(job)
162
+ end, POLL_TIMEOUT_MS)
163
+ end
164
+
31
165
  function M.start_poll(config, interval_ms)
32
166
  if poll_timer then
33
167
  return
@@ -36,12 +170,7 @@ function M.start_poll(config, interval_ms)
36
170
 
37
171
  poll_timer = uv.new_timer()
38
172
  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
- })
173
+ poll_once(config)
45
174
  end))
46
175
  end
47
176
 
@@ -51,6 +180,15 @@ function M.stop_poll()
51
180
  poll_timer:close()
52
181
  poll_timer = nil
53
182
  end
183
+ if poll_job then
184
+ -- Exactly the watchdog's sequence, for the same reason: `jobstop` here
185
+ -- would hand the CLI nvim's ~2s kill timer and could SIGKILL it between the
186
+ -- OAuth grant and the rotated token being persisted. Teardown and
187
+ -- reconfiguration deserve the same grace as a timeout. (On `VimLeave` nvim
188
+ -- exits before any of it elapses — the CLI's own retry-not-delete rule is
189
+ -- what covers that case.)
190
+ terminate(poll_job)
191
+ end
54
192
  end
55
193
 
56
194
  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.2.1",
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": {