@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.
- package/dist/package.json +10 -9
- package/dist/src/cli/fetchTicket.js +2 -1
- package/dist/src/cli/fetchTicket.js.map +1 -1
- package/dist/src/cli/fuzzySelect.js.map +1 -1
- package/dist/src/cli/layers.js +18 -0
- package/dist/src/cli/layers.js.map +1 -1
- package/dist/src/cli/setup.js +1 -1
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/timer/edit.js +8 -8
- package/dist/src/cli/timer/edit.js.map +1 -1
- package/dist/src/cli/timer/start.js +2 -2
- package/dist/src/cli/timer/start.js.map +1 -1
- package/dist/src/cli/timer/status.js +16 -4
- package/dist/src/cli/timer/status.js.map +1 -1
- package/dist/src/cli/timer/stop.js +1 -1
- package/dist/src/cli/timer/stop.js.map +1 -1
- package/dist/src/services/ClockifyAuth.js.map +1 -1
- package/dist/src/services/ConfigService.js +11 -15
- package/dist/src/services/ConfigService.js.map +1 -1
- package/dist/src/services/HomeDirectory.js.map +1 -1
- package/dist/src/services/ReconcileService.js +6 -6
- package/dist/src/services/ReconcileService.js.map +1 -1
- package/dist/src/services/StateWriter.js +8 -8
- package/dist/src/services/StateWriter.js.map +1 -1
- package/dist/src/services/TicketService.js +13 -10
- package/dist/src/services/TicketService.js.map +1 -1
- package/dist/src/services/TimerService.js +20 -22
- package/dist/src/services/TimerService.js.map +1 -1
- package/dist/src/tui/App.js +1 -1
- package/dist/src/tui/App.js.map +1 -1
- package/dist/src/tui/components/BigTimer.js +0 -1
- package/dist/src/tui/components/BigTimer.js.map +1 -1
- package/dist/src/tui/components/PopupInput.js +2 -1
- package/dist/src/tui/components/PopupInput.js.map +1 -1
- package/dist/src/tui/components/TicketRow.js +1 -1
- package/dist/src/tui/components/TicketRow.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/nvim/lua/jcf/state.lua +148 -10
- package/package.json +14 -13
package/nvim/lua/jcf/state.lua
CHANGED
|
@@ -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
|
|
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
|
-
|
|
35
|
+
local key = stat_key(stat)
|
|
36
|
+
if key == last_key then
|
|
14
37
|
return cached
|
|
15
38
|
end
|
|
16
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
21
|
-
"@effect/platform-node": "4.0.0-
|
|
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-
|
|
25
|
-
"react": "^19.2.
|
|
26
|
-
"@knpkv/agent-skills": "^0.
|
|
27
|
-
"@knpkv/atlassian-common": "^1.
|
|
28
|
-
"@knpkv/clockify-api-client": "^1.
|
|
29
|
-
"@knpkv/jira-api-client": "^1.
|
|
30
|
-
"@knpkv/jira-cli": "^1.
|
|
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-
|
|
34
|
+
"@effect/vitest": "4.0.0-rc.109",
|
|
34
35
|
"@types/node": "latest",
|
|
35
|
-
"@types/react": "^19.2.
|
|
36
|
-
"tsx": "^4.23.
|
|
36
|
+
"@types/react": "^19.2.18",
|
|
37
|
+
"tsx": "^4.23.12",
|
|
37
38
|
"vitest": "^4.1.10"
|
|
38
39
|
},
|
|
39
40
|
"scripts": {
|