ticketlens 0.39.0 → 0.39.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.39.0",
3
+ "version": "0.39.2",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -70,3 +70,74 @@ export function recordAction(ticketKey, action, { configDir = DEFAULT_CONFIG_DIR
70
70
  cooldowns[cooldownKey(ticketKey, action)] = new Date(now()).toISOString();
71
71
  writeFileAtomically(cooldownPath(configDir), JSON.stringify(cooldowns));
72
72
  }
73
+
74
+ const LOCK_STALE_MS = 5_000;
75
+ const LOCK_POLL_MS = 15;
76
+
77
+ function sleepSync(ms) {
78
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
79
+ }
80
+
81
+ /**
82
+ * Cross-process mutex around the cooldown file's read-modify-write, via an
83
+ * O_EXCL lock file. Fails closed: if the lock cannot be taken in `lockWaitMs`
84
+ * the caller gets an error, never an unguarded claim. A lock older than
85
+ * LOCK_STALE_MS belongs to a crashed process and is cleared.
86
+ */
87
+ function withLock(configDir, lockWaitMs, fn) {
88
+ fs.mkdirSync(configDir, { recursive: true });
89
+ const lockPath = `${cooldownPath(configDir)}.lock`;
90
+ const deadline = Date.now() + lockWaitMs;
91
+ let fd;
92
+ while (fd === undefined) {
93
+ try {
94
+ fd = fs.openSync(lockPath, 'wx');
95
+ } catch (err) {
96
+ if (err.code !== 'EEXIST') throw err;
97
+ try {
98
+ if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS) { fs.rmSync(lockPath, { force: true }); continue; }
99
+ } catch { /* lock vanished between exists-check and stat — just retry */ }
100
+ if (Date.now() > deadline) throw new Error('cooldown lock busy — another ticketlens process holds it');
101
+ sleepSync(LOCK_POLL_MS);
102
+ }
103
+ }
104
+ try {
105
+ return fn();
106
+ } finally {
107
+ fs.closeSync(fd);
108
+ fs.rmSync(lockPath, { force: true });
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Atomic check-and-set: unlike checkCooldown-then-recordAction, two processes
114
+ * cannot both pass the check. Use before a write that must never double-fire
115
+ * (billable time) — claim BEFORE the network call, `releaseAction` on a
116
+ * definite failure so a corrected retry isn't blocked.
117
+ *
118
+ * @returns {{ claimed: boolean, remainingMs: number }}
119
+ */
120
+ export function claimAction(ticketKey, action, {
121
+ configDir = DEFAULT_CONFIG_DIR,
122
+ cooldownMs = DEFAULT_COOLDOWN_MS,
123
+ now = () => Date.now(),
124
+ lockWaitMs = 2_000,
125
+ } = {}) {
126
+ return withLock(configDir, lockWaitMs, () => {
127
+ const status = checkCooldown(ticketKey, action, { configDir, cooldownMs, now });
128
+ if (status.active) return { claimed: false, remainingMs: status.remainingMs };
129
+ recordAction(ticketKey, action, { configDir, now });
130
+ return { claimed: true, remainingMs: 0 };
131
+ });
132
+ }
133
+
134
+ /** Frees a claim. A key that was never claimed is a no-op. */
135
+ export function releaseAction(ticketKey, action, { configDir = DEFAULT_CONFIG_DIR, lockWaitMs = 2_000 } = {}) {
136
+ withLock(configDir, lockWaitMs, () => {
137
+ const cooldowns = readCooldowns(configDir);
138
+ const key = cooldownKey(ticketKey, action);
139
+ if (!(key in cooldowns)) return;
140
+ const { [key]: _released, ...rest } = cooldowns;
141
+ writeFileAtomically(cooldownPath(configDir), JSON.stringify(rest));
142
+ });
143
+ }
@@ -20,7 +20,7 @@ import { DEFAULT_CONFIG_DIR } from './config.mjs';
20
20
  import { isLicensed } from './license.mjs';
21
21
  import { resolveConnection } from './profile-resolver.mjs';
22
22
  import { resolveAdapter } from './resolve-adapter.mjs';
23
- import { checkCooldown, recordAction } from './ticket-action-cooldown.mjs';
23
+ import { checkCooldown, recordAction, claimAction, releaseAction } from './ticket-action-cooldown.mjs';
24
24
  import { logAction } from './ticket-action-log.mjs';
25
25
  import { TICKET_KEY_PATTERN, normalizeTicketKey } from './cli.mjs';
26
26
  import { createStyler, sanitizeUntrustedText } from './ansi.mjs';
@@ -39,6 +39,9 @@ const UNCONFIRMED_WINDOW_MS = 10 * 60 * 1000;
39
39
  const USAGE = 'Usage: ticketlens worklog KEY=DURATION [KEY=DURATION ...] [--comment="..."] [--started=ISO] [--profile=NAME] --confirm\n';
40
40
  const COMMENT_PREVIEW_CHARS = 60;
41
41
 
42
+ /** Outcomes where the POST may have landed even though the caller saw a failure. */
43
+ const AMBIGUOUS_KINDS = new Set(['network-or-timeout', 'server-error']);
44
+
42
45
  const invalid = (error) => ({ error });
43
46
  const refused = (reason) => ({ ok: false, reason, results: [] });
44
47
  const fromResult = (result, valueKey) => (result.ok ? { value: result[valueKey] } : invalid(result.error));
@@ -167,22 +170,26 @@ function recordBookkeeping(item, result, { configDir, recordActionFn, logActionF
167
170
  /** Tracker text is untrusted: strip terminal control characters per line, keeping the line structure. */
168
171
  const safeLines = (text) => text.split('\n').map(sanitizeUntrustedText).join('\n');
169
172
 
173
+ const recentSkip = (ticket, remainingMs) => ({
174
+ reason: 'recent',
175
+ text: ` Skipped ${ticket} — a worklog was already logged just now and is not repeated (blocked ${Math.ceil(remainingMs / 1000)}s more). Check the ticket before logging again.\n`,
176
+ });
177
+
170
178
  /** Checked before every write: a recent worklog, or a recent write whose outcome is still unknown. */
171
179
  function skipReason(item, { configDir, checkCooldownFn }) {
172
180
  const unconfirmed = checkCooldownFn(item.ticket, 'worklog-unconfirmed', { configDir, cooldownMs: UNCONFIRMED_WINDOW_MS });
173
181
  if (unconfirmed.active) {
174
- return ` Skipped ${item.ticket} — an earlier write to it ended without a confirmed result (timeout or server error) ${Math.ceil(unconfirmed.remainingMs / 60000)}m ago and may have already landed. Check the ticket in Jira before logging again.\n`;
182
+ return { reason: 'unconfirmed', text: ` Skipped ${item.ticket} — an earlier write to it ended without a confirmed result (timeout or server error) and may have already landed (blocked ${Math.ceil(unconfirmed.remainingMs / 60000)}m more). Check the ticket in Jira before logging again.\n` };
175
183
  }
176
184
  const recent = checkCooldownFn(item.ticket, 'worklog', { configDir });
177
185
  if (recent.active) {
178
- return ` Skipped ${item.ticket} — a worklog was already logged ${Math.ceil(recent.remainingMs / 1000)}s ago and is not repeated. Check the ticket before logging again.\n`;
186
+ return recentSkip(item.ticket, recent.remainingMs);
179
187
  }
180
188
  return null;
181
189
  }
182
190
 
183
191
  /** Timeout / 5xx: the POST may have landed. Leave a long cooldown and an audit line so a retry can't silently double-bill. */
184
192
  function recordUnconfirmed(item, classification, { configDir, recordActionFn, logActionFn, actor, stream, source }) {
185
- if (classification.kind !== 'network-or-timeout' && classification.kind !== 'server-error') return;
186
193
  try {
187
194
  logActionFn({ ticketKey: item.ticket, action: 'worklog-unconfirmed', actor, tracker: item.adapter.type, detail: { seconds: item.seconds, started: item.started, kind: classification.kind, source } }, { configDir });
188
195
  recordActionFn(item.ticket, 'worklog-unconfirmed', { configDir });
@@ -191,12 +198,31 @@ function recordUnconfirmed(item, classification, { configDir, recordActionFn, lo
191
198
  }
192
199
  }
193
200
 
201
+ /**
202
+ * Claims the ticket's cooldown atomically BEFORE the POST — a check-then-write
203
+ * lets two parallel processes both pass the check and both bill the time.
204
+ * @returns {{ reason: string, text: string } | null} a skip, or null when the claim is ours
205
+ */
206
+ function claimOrSkip(item, { configDir, claimActionFn }) {
207
+ try {
208
+ const claim = claimActionFn(item.ticket, 'worklog', { configDir });
209
+ return claim.claimed ? null : recentSkip(item.ticket, claim.remainingMs);
210
+ } catch (err) {
211
+ return { reason: 'lock', text: ` ${item.ticket} not written — could not take the cooldown lock (${sanitizeUntrustedText(String(err.message))}). Nothing was sent; safe to retry.\n` };
212
+ }
213
+ }
214
+
215
+ /** A definite rejection sent nothing that landed: free the claim so a corrected retry isn't blocked. */
216
+ function releaseClaim(item, { configDir, releaseActionFn }) {
217
+ try { releaseActionFn(item.ticket, 'worklog', { configDir }); } catch { /* best effort — the claim expires by itself */ }
218
+ }
219
+
194
220
  async function logOne(item, ctx) {
195
221
  const { stream } = ctx;
196
- const skip = skipReason(item, ctx);
222
+ const skip = skipReason(item, ctx) ?? claimOrSkip(item, ctx);
197
223
  if (skip) {
198
- stream.write(skip);
199
- return { ticket: item.ticket, status: 'skipped', reason: 'cooldown' };
224
+ stream.write(skip.text);
225
+ return { ticket: item.ticket, status: 'skipped', reason: skip.reason };
200
226
  }
201
227
  const entry = { timeSpentSeconds: item.seconds, started: item.started, ...(item.comment !== undefined && { comment: item.comment }) };
202
228
  let result;
@@ -205,7 +231,8 @@ async function logOne(item, ctx) {
205
231
  } catch (err) {
206
232
  const classification = classifyWriteFailure(err);
207
233
  stream.write(safeLines(formatWriteFailure(item.ticket, err)));
208
- recordUnconfirmed(item, classification, ctx);
234
+ if (AMBIGUOUS_KINDS.has(classification.kind)) recordUnconfirmed(item, classification, ctx);
235
+ else releaseClaim(item, ctx);
209
236
  return { ticket: item.ticket, status: 'failed', error: sanitizeUntrustedText(String(err.message)), kind: classification.kind, httpStatus: err.status };
210
237
  }
211
238
  const s = createStyler({ isTTY: stream.isTTY });
@@ -221,13 +248,23 @@ function haltReason(result) {
221
248
  return null;
222
249
  }
223
250
 
224
- /** Names what landed and what to retry, so a caller (often an AI) never re-sends a worklog that already went through. */
251
+ /**
252
+ * Sorts every result into exactly one of three actions for the caller (often an
253
+ * AI): already landed (never repeat), unknown outcome (check Jira first), or
254
+ * safe to retry. A ticket skipped as "already logged" must never land under
255
+ * retry — a blind retry after the 10s debounce would double-bill it.
256
+ */
225
257
  function writeBatchSummary(results, stream) {
226
- const logged = results.filter(r => r.status === 'logged').map(r => r.ticket);
227
- const pending = results.filter(r => r.status !== 'logged').map(r => r.ticket);
228
- stream.write(`\n Logged ${logged.length} of ${results.length} worklogs.\n`);
229
- if (logged.length && pending.length) stream.write(` Already logged (do not repeat): ${logged.join(', ')}.\n`);
230
- if (pending.length) stream.write(` Retry only: ${pending.join(', ')}.\n`);
258
+ const isLanded = (r) => r.status === 'logged' || r.reason === 'recent';
259
+ const isUnknown = (r) => r.reason === 'unconfirmed' || (r.status === 'failed' && AMBIGUOUS_KINDS.has(r.kind));
260
+ const names = (pick) => results.filter(pick).map(r => r.ticket).join(', ');
261
+ const landed = names(isLanded);
262
+ const unknown = names(isUnknown);
263
+ const retry = names(r => !isLanded(r) && !isUnknown(r));
264
+ stream.write(`\n Logged ${results.filter(r => r.status === 'logged').length} of ${results.length} worklogs.\n`);
265
+ if (landed && (unknown || retry)) stream.write(` Already logged (do not repeat): ${landed}.\n`);
266
+ if (unknown) stream.write(` Check in Jira before logging again (may have landed): ${unknown}.\n`);
267
+ if (retry) stream.write(` Retry only: ${retry}.\n`);
231
268
  }
232
269
 
233
270
  async function logAll(items, ctx) {
@@ -266,6 +303,8 @@ export async function runTicketWorklogEntries(entries, {
266
303
  resolveConnectionFn = resolveConnection,
267
304
  resolveAdapterFn = resolveAdapter,
268
305
  checkCooldownFn = checkCooldown,
306
+ claimActionFn = claimAction,
307
+ releaseActionFn = releaseAction,
269
308
  recordActionFn = recordAction,
270
309
  logActionFn = logAction,
271
310
  actor = os.userInfo().username,
@@ -282,7 +321,7 @@ export async function runTicketWorklogEntries(entries, {
282
321
  writePreview(resolved, { stream, cliHints });
283
322
  return refused('confirm-required');
284
323
  }
285
- return logAll(resolved, { configDir, checkCooldownFn, recordActionFn, logActionFn, actor, stream, source: cliHints ? 'cli' : 'mcp' });
324
+ return logAll(resolved, { configDir, checkCooldownFn, claimActionFn, releaseActionFn, recordActionFn, logActionFn, actor, stream, source: cliHints ? 'cli' : 'mcp' });
286
325
  }
287
326
 
288
327
  function parsePair(arg) {