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
|
@@ -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
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
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
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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) {
|