ticketlens 0.21.7 → 0.21.9
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/README.md +6 -2
- package/bin/ticketlens.mjs +13 -2
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +1 -0
- package/skills/jtb/scripts/lib/help.mjs +2 -0
- package/skills/jtb/scripts/lib/note-command.mjs +1 -1
- package/skills/jtb/scripts/lib/recall-command.mjs +44 -8
- package/skills/jtb/scripts/lib/recall-queue.mjs +53 -23
- package/skills/jtb/scripts/lib/recall-settings-sync.mjs +168 -0
package/README.md
CHANGED
|
@@ -401,9 +401,11 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
401
401
|
|
|
402
402
|
**Quality loop:** inside a Claude Code session using the jtb skill, a saved note can be silently refined afterward — a generator subagent drafts a more actionable version, a validator subagent checks it against other notes on the same ticket for duplication, up to 3 rounds — and the improved draft overwrites the original via the internal `note patch` command (not typically invoked by hand). This makes zero API calls and costs zero extra tokens beyond your already-running session; it never runs for a bare shell invocation of `note add`, which is skipped silently. Known limitation: a refined draft is not re-synced to your team even if the original was — teammates who already pulled the note keep the earlier draft.
|
|
403
403
|
|
|
404
|
-
**Team sync:** on a Team plan with Recall enabled for your account (owner-managed, per-tier or per-client), notes also sync to your team's shared pool — `note add` pushes in the background, `recall` pulls the team's notes (
|
|
404
|
+
**Team sync:** on a Team plan with Recall enabled for your account (owner-managed, per-tier or per-client), notes also sync to your team's shared pool — `note add` pushes in the background, `recall` always pulls the team's notes fresh before searching, so a manager's verify/delete in Console is visible on that very search. (Ticket-brief injection uses a separate, short-timeout 4h-cached pull so it never slows down your everyday `ticketlens PROJ-123` — it doesn't need to be instant the way an explicit search does.) A team manager reviews and verifies incoming notes at `console/admin/recall` before they're marked trusted. Without Team Recall entitlement, everything stays on your machine — no network call.
|
|
405
405
|
|
|
406
|
-
**Offline resilience:** if a team push fails for a transient reason (network error, timeout, or a 5xx from the backend), the note stays safely in your local vault and is queued for retry — nothing is lost. The queue flushes automatically in the background
|
|
406
|
+
**Offline resilience:** if a team push fails for a transient reason (network error, timeout, or a 5xx from the backend), the note stays safely in your local vault and is queued for retry — nothing is lost. The queue flushes automatically in the background before every command, not just `recall`/`note add` — a short timeout on that check means it never stalls an unrelated command — or on demand with `ticketlens recall sync`. A session-expired (401) or not-entitled (403) push is never queued — those need you to act (`ticketlens login`, or an owner grant), not a retry. Switching accounts never flushes a note under the wrong login.
|
|
407
|
+
|
|
408
|
+
**Queue settings:** the retry cooldown (default 15 min), per-request timeout (4s), max queued notes (200), and queued-note expiry (30 days) are set by your team manager at `console/admin/recall` and apply to every member's CLI — solo users get the same platform defaults. `ticketlens recall settings` shows the values currently in effect, fetched live: a manager's change is visible the moment your CLI's next retry decision runs, not on a delay.
|
|
407
409
|
|
|
408
410
|
**Removing a note:** `ticketlens note delete --id="..." [--ticket=KEY]` removes a note from your local vault. Local only — if it was already pushed to a team, teammates who pulled it keep their copy; deleting it there too is a manager action from the Console (Admin > Recall).
|
|
409
411
|
|
|
@@ -684,6 +686,7 @@ ticketlens note delete --id="..." --ticket=CNV1-2 # Remove a note from your loc
|
|
|
684
686
|
ticketlens recall CNV1-2 # Search saved notes by ticket key [Pro]
|
|
685
687
|
ticketlens recall "retry backoff" # Free-text search across all notes [Pro]
|
|
686
688
|
ticketlens recall sync # Retry any notes stuck in the local queue [Pro]
|
|
689
|
+
ticketlens recall settings # Show effective retry-queue settings, fetched live [Pro]
|
|
687
690
|
|
|
688
691
|
# ── Stats ──────────────────────────────────────────────────────────────────────
|
|
689
692
|
ticketlens stats # Response-time metrics from local history
|
|
@@ -764,6 +767,7 @@ ticketlens note add --title="..." # Save a Recall note (body from stdin)
|
|
|
764
767
|
ticketlens note delete --id="..." # Remove a note from your local vault
|
|
765
768
|
ticketlens recall <query|TICKET-KEY> # Search your saved Recall notes
|
|
766
769
|
ticketlens recall sync # Retry any notes stuck in the local queue
|
|
770
|
+
ticketlens recall settings # Show effective retry-queue settings, fetched live
|
|
767
771
|
ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
|
|
768
772
|
```
|
|
769
773
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -70,9 +70,10 @@ checkForUpdate();
|
|
|
70
70
|
try {
|
|
71
71
|
const cliToken = readCliToken(DEFAULT_CONFIG_DIR);
|
|
72
72
|
if (cliToken) {
|
|
73
|
-
//
|
|
73
|
+
// Timeout defaults to the effective settings' timeout_ms (Console-managed,
|
|
74
|
+
// 4s by default) — this runs before every command, unrelated to Recall or
|
|
74
75
|
// not, so it must never be the reason an unrelated command feels slow.
|
|
75
|
-
const result = await maybeAutoFlush({ cliToken, configDir: DEFAULT_CONFIG_DIR
|
|
76
|
+
const result = await maybeAutoFlush({ cliToken, configDir: DEFAULT_CONFIG_DIR });
|
|
76
77
|
if (result?.flushed) {
|
|
77
78
|
process.stderr.write(` ✔ Synced ${result.flushed} pending Recall note${result.flushed === 1 ? '' : 's'}.\n`);
|
|
78
79
|
}
|
|
@@ -678,6 +679,16 @@ switch (command) {
|
|
|
678
679
|
});
|
|
679
680
|
break;
|
|
680
681
|
}
|
|
682
|
+
if (cmdArgs[0] === 'settings') {
|
|
683
|
+
const { runRecallSettings } = await import('../skills/jtb/scripts/lib/recall-command.mjs');
|
|
684
|
+
runRecallSettings(cmdArgs.slice(1)).then(({ ok }) => {
|
|
685
|
+
if (!ok) process.exitCode = 1;
|
|
686
|
+
}).catch(err => {
|
|
687
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
688
|
+
process.exitCode = 1;
|
|
689
|
+
});
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
681
692
|
const { runRecall } = await import('../skills/jtb/scripts/lib/recall-command.mjs');
|
|
682
693
|
runRecall(cmdArgs).then(({ ok }) => {
|
|
683
694
|
if (!ok) process.exitCode = 1;
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -55,6 +55,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
55
55
|
/jtb note "gotcha text" --ticket=PROD-1234 # save a Recall note (Pro)
|
|
56
56
|
/jtb recall PROD-1234 # search saved Recall notes (Pro)
|
|
57
57
|
/jtb recall sync # retry any notes stuck in the local queue (Pro)
|
|
58
|
+
/jtb recall settings # show effective retry-queue settings, fetched live (Pro)
|
|
58
59
|
```
|
|
59
60
|
|
|
60
61
|
## Prerequisites
|
|
@@ -548,6 +548,7 @@ export function printRecallHelp({ stream = process.stdout } = {}) {
|
|
|
548
548
|
` ${s.bold('COMMANDS')}`,
|
|
549
549
|
'',
|
|
550
550
|
` ${s.brand('sync')} Manually retry any team-synced notes stuck in the local retry queue ${s.dim('[Pro, requires login]')}`,
|
|
551
|
+
` ${s.brand('settings')} Show effective retry-queue settings (cooldown, timeout, queue limits) ${s.dim('[Pro]')}`,
|
|
551
552
|
'',
|
|
552
553
|
` ${s.bold('EXAMPLES')}`,
|
|
553
554
|
'',
|
|
@@ -556,6 +557,7 @@ export function printRecallHelp({ stream = process.stdout } = {}) {
|
|
|
556
557
|
` ${s.dim('$')} ticketlens recall PROD-123 --plain`,
|
|
557
558
|
` ${s.dim('$')} ticketlens recall PROD-123 --full`,
|
|
558
559
|
` ${s.dim('$')} ticketlens recall sync`,
|
|
560
|
+
` ${s.dim('$')} ticketlens recall settings`,
|
|
559
561
|
'',
|
|
560
562
|
];
|
|
561
563
|
stream.write(lines.join('\n') + '\n');
|
|
@@ -141,7 +141,7 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
141
141
|
const payload = { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body };
|
|
142
142
|
const result = await pushNoteFn(payload, { cliToken, configDir, warn });
|
|
143
143
|
if (isRetryableFailureFn(result)) {
|
|
144
|
-
enqueueNoteFn(payload, { cliToken, configDir, warn });
|
|
144
|
+
await enqueueNoteFn(payload, { cliToken, configDir, warn });
|
|
145
145
|
}
|
|
146
146
|
await maybeAutoFlushFn({ cliToken, configDir });
|
|
147
147
|
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Implements `tl recall <query|TICKET-KEY>` — a search over saved Recall
|
|
3
|
-
* notes, local-first. When logged in, pulls the team vault
|
|
4
|
-
* searching (
|
|
5
|
-
*
|
|
3
|
+
* notes, local-first. When logged in, always pulls the team vault fresh
|
|
4
|
+
* before searching (ttlMs: 0, bypassing the TTL cache entirely) — the user
|
|
5
|
+
* is explicitly waiting on this command, so a manager's Console verify/
|
|
6
|
+
* delete action is visible on this very invocation, not up to 4h later.
|
|
7
|
+
* The full request timeout applies here — unlike the passive brief-fetch
|
|
8
|
+
* pull path (fetch-ticket.mjs), which deliberately keeps its short timeout
|
|
9
|
+
* and TTL cache since it runs on nearly every command, not just this one.
|
|
6
10
|
* A pull failure never blocks the local search from returning results.
|
|
7
11
|
*/
|
|
8
12
|
|
|
@@ -13,6 +17,7 @@ import { listNotes } from './recall-vault.mjs';
|
|
|
13
17
|
import { readCliToken } from './cli-auth.mjs';
|
|
14
18
|
import { pullNotes } from './recall-sync.mjs';
|
|
15
19
|
import { maybeAutoFlush, flushQueue, readQueue } from './recall-queue.mjs';
|
|
20
|
+
import { getEffectiveRecallSettings } from './recall-settings-sync.mjs';
|
|
16
21
|
import { styleRecallResults } from './styled-assembler.mjs';
|
|
17
22
|
|
|
18
23
|
/**
|
|
@@ -42,11 +47,7 @@ export async function runRecall(cmdArgs, {
|
|
|
42
47
|
|
|
43
48
|
const cliToken = readCliTokenFn(configDir);
|
|
44
49
|
if (cliToken) {
|
|
45
|
-
await pullNotesFn({
|
|
46
|
-
cliToken,
|
|
47
|
-
configDir,
|
|
48
|
-
...(cmdArgs.includes('--no-cache') && { ttlMs: 0 }),
|
|
49
|
-
});
|
|
50
|
+
await pullNotesFn({ cliToken, configDir, ttlMs: 0 });
|
|
50
51
|
await maybeAutoFlushFn({ cliToken, configDir });
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -96,3 +97,38 @@ export async function runRecallSync(cmdArgs, {
|
|
|
96
97
|
stream.write(`Synced ${flushed} note(s). ${remaining} still pending.\n`);
|
|
97
98
|
return { ok: true };
|
|
98
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Implements `tl recall settings` — read-only display of the effective
|
|
103
|
+
* Recall queue settings (flush cooldown, per-request timeout, max queue
|
|
104
|
+
* size, max entry age). Management happens in Console (console/admin/recall,
|
|
105
|
+
* manager-only); this fetches live so it always reflects the manager's
|
|
106
|
+
* latest saved value, falling back to the last-known cache/platform default
|
|
107
|
+
* if offline.
|
|
108
|
+
*
|
|
109
|
+
* @param {string[]} cmdArgs
|
|
110
|
+
* @returns {Promise<{ ok: boolean }>}
|
|
111
|
+
*/
|
|
112
|
+
export async function runRecallSettings(cmdArgs, {
|
|
113
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
114
|
+
stream = process.stdout,
|
|
115
|
+
isLicensedFn = isLicensed,
|
|
116
|
+
readCliTokenFn = readCliToken,
|
|
117
|
+
getEffectiveRecallSettingsFn = getEffectiveRecallSettings,
|
|
118
|
+
} = {}) {
|
|
119
|
+
if (!isLicensedFn('pro', configDir)) {
|
|
120
|
+
showUpgradePrompt('pro', 'ticketlens recall', { stream });
|
|
121
|
+
return { ok: false };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const cliToken = readCliTokenFn(configDir);
|
|
125
|
+
const settings = await getEffectiveRecallSettingsFn({ cliToken, configDir });
|
|
126
|
+
const source = cliToken ? 'your team manager (or platform default if unset)' : 'platform default — log in to pick up a team override';
|
|
127
|
+
|
|
128
|
+
stream.write(` Retry cooldown: ${settings.flush_cooldown_ms / 60_000} min\n`);
|
|
129
|
+
stream.write(` Per-request timeout: ${settings.timeout_ms / 1_000} sec\n`);
|
|
130
|
+
stream.write(` Max queued notes: ${settings.max_queue_size}\n`);
|
|
131
|
+
stream.write(` Queued note expiry: ${settings.max_entry_age_ms / 86_400_000} days\n`);
|
|
132
|
+
stream.write(` Source: ${source}\n`);
|
|
133
|
+
return { ok: true };
|
|
134
|
+
}
|
|
@@ -29,13 +29,17 @@ import path from 'node:path';
|
|
|
29
29
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
30
30
|
import { writeFileAtomically } from './recall-vault.mjs';
|
|
31
31
|
import { pushNote, hashToken } from './recall-sync.mjs';
|
|
32
|
+
import { getEffectiveRecallSettings, DEFAULT_RECALL_SETTINGS } from './recall-settings-sync.mjs';
|
|
32
33
|
|
|
33
34
|
const QUEUE_FILE = 'recall-pending.json';
|
|
34
35
|
const FLUSH_STATE_FILE = 'recall-flush-state.json';
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// Platform defaults — actual effective values now come from
|
|
38
|
+
// getEffectiveRecallSettings(), which is the manager's Console override for
|
|
39
|
+
// their team if one exists (fetched live), else these same numbers.
|
|
40
|
+
export const DEFAULT_MAX_QUEUE_SIZE = DEFAULT_RECALL_SETTINGS.max_queue_size;
|
|
41
|
+
export const DEFAULT_MAX_ENTRY_AGE_MS = DEFAULT_RECALL_SETTINGS.max_entry_age_ms;
|
|
42
|
+
export const DEFAULT_AUTO_FLUSH_INTERVAL_MS = DEFAULT_RECALL_SETTINGS.flush_cooldown_ms;
|
|
39
43
|
|
|
40
44
|
function queuePath(configDir) {
|
|
41
45
|
return path.join(configDir, QUEUE_FILE);
|
|
@@ -62,8 +66,8 @@ function writeQueue(configDir, entries) {
|
|
|
62
66
|
writeFileAtomically(queuePath(configDir), JSON.stringify(entries));
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
function purgeExpired(entries, now) {
|
|
66
|
-
return entries.filter(entry => now - new Date(entry.firstQueuedAt).getTime() <=
|
|
69
|
+
function purgeExpired(entries, now, maxEntryAgeMs) {
|
|
70
|
+
return entries.filter(entry => now - new Date(entry.firstQueuedAt).getTime() <= maxEntryAgeMs);
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
/**
|
|
@@ -84,7 +88,10 @@ export function isRetryableFailure(result) {
|
|
|
84
88
|
/**
|
|
85
89
|
* Queues a note for later retry after a transient push failure. Purges
|
|
86
90
|
* expired entries first, then evicts the oldest entry (with a single warn)
|
|
87
|
-
* if appending would exceed
|
|
91
|
+
* if appending would exceed the effective max queue size (Console-managed
|
|
92
|
+
* per team, fetched live — see recall-settings-sync.mjs). Async: a push just
|
|
93
|
+
* failed, so one more short network call to get the current cap/expiry
|
|
94
|
+
* doesn't change this path's performance characteristics.
|
|
88
95
|
*
|
|
89
96
|
* @param {object} notePayload - exact wire payload passed to pushNote
|
|
90
97
|
* @param {object} opts
|
|
@@ -92,17 +99,20 @@ export function isRetryableFailure(result) {
|
|
|
92
99
|
* @param {string} [opts.configDir]
|
|
93
100
|
* @param {() => number} [opts.now]
|
|
94
101
|
* @param {Function} [opts.warn]
|
|
102
|
+
* @returns {Promise<void>}
|
|
95
103
|
*/
|
|
96
|
-
export function enqueueNote(notePayload, {
|
|
104
|
+
export async function enqueueNote(notePayload, {
|
|
97
105
|
cliToken,
|
|
98
106
|
configDir = DEFAULT_CONFIG_DIR,
|
|
99
107
|
now = () => Date.now(),
|
|
100
108
|
warn = (s) => process.stderr.write(s),
|
|
109
|
+
getEffectiveRecallSettingsFn = getEffectiveRecallSettings,
|
|
101
110
|
} = {}) {
|
|
102
111
|
const nowMs = now();
|
|
103
|
-
|
|
112
|
+
const settings = await getEffectiveRecallSettingsFn({ cliToken, configDir });
|
|
113
|
+
let entries = purgeExpired(readQueue(configDir), nowMs, settings.max_entry_age_ms);
|
|
104
114
|
|
|
105
|
-
if (entries.length >=
|
|
115
|
+
if (entries.length >= settings.max_queue_size) {
|
|
106
116
|
entries = entries.slice(1);
|
|
107
117
|
warn(' Recall queue full — dropped the oldest queued note to make room.\n');
|
|
108
118
|
}
|
|
@@ -142,10 +152,22 @@ export async function flushQueue({
|
|
|
142
152
|
warn = () => {},
|
|
143
153
|
now = () => Date.now(),
|
|
144
154
|
timeoutMs,
|
|
155
|
+
settings,
|
|
156
|
+
getEffectiveRecallSettingsFn = getEffectiveRecallSettings,
|
|
145
157
|
} = {}) {
|
|
146
158
|
const nowMs = now();
|
|
147
159
|
const currentHash = hashToken(cliToken);
|
|
148
|
-
|
|
160
|
+
// Only the age bound comes from settings here — timeoutMs is caller-supplied
|
|
161
|
+
// or falls through to pushNote's own default. maybeAutoFlush (below) is the
|
|
162
|
+
// one call site that maps the Console-configured timeout_ms onto this param;
|
|
163
|
+
// runRecallSync's manual, user-initiated flush deliberately keeps a longer,
|
|
164
|
+
// uncapped-by-this-setting timeout since the user is actively waiting.
|
|
165
|
+
// `settings` is an optional already-fetched value — maybeAutoFlush passes
|
|
166
|
+
// its own fetch through here so a single auto-flush attempt never fetches
|
|
167
|
+
// settings twice; a direct/manual call (runRecallSync) has none yet, so
|
|
168
|
+
// this fetches its own.
|
|
169
|
+
const effectiveSettings = settings ?? await getEffectiveRecallSettingsFn({ cliToken, configDir });
|
|
170
|
+
const entries = purgeExpired(readQueue(configDir), nowMs, effectiveSettings.max_entry_age_ms);
|
|
149
171
|
|
|
150
172
|
let flushed = 0;
|
|
151
173
|
const remaining = [];
|
|
@@ -199,14 +221,15 @@ function writeLastFlushAttemptAt(configDir, isoTimestamp) {
|
|
|
199
221
|
* a burst of failures (e.g. a debugging session) would otherwise sit queued
|
|
200
222
|
* until the user happens to run `note add`/`recall` again, or runs
|
|
201
223
|
* `recall sync` by hand. A no-op unless the queue is non-empty AND at least
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
224
|
+
* the effective flush cooldown (Console-managed per team, defaults to 15
|
|
225
|
+
* minutes — see recall-settings-sync.mjs) has passed since the last attempt.
|
|
226
|
+
* The interval is a single global cooldown, not per-entry, so a burst of
|
|
227
|
+
* failures in one short window still only gets one automatic retry pass, by
|
|
228
|
+
* design: this runs on every command now, so the next opportunity is never
|
|
229
|
+
* far away, and the cooldown exists specifically to stop a down backend from
|
|
230
|
+
* being hit by every single command in the meantime. The attempt timestamp is
|
|
231
|
+
* recorded even on failure, so a down backend can't be hammered once per
|
|
232
|
+
* command within the window.
|
|
210
233
|
*
|
|
211
234
|
* @param {object} opts
|
|
212
235
|
* @param {string} opts.cliToken
|
|
@@ -214,9 +237,10 @@ function writeLastFlushAttemptAt(configDir, isoTimestamp) {
|
|
|
214
237
|
* @param {() => number} [opts.now]
|
|
215
238
|
* @param {Function} [opts.flushQueueFn]
|
|
216
239
|
* @param {number} [opts.timeoutMs] - per-request timeout, passed through to
|
|
217
|
-
* pushNote.
|
|
218
|
-
*
|
|
219
|
-
* must feel instant, not stall an unrelated command behind a
|
|
240
|
+
* pushNote. Defaults to the effective settings' timeout_ms (Console-managed,
|
|
241
|
+
* defaults to 4s) — short, because this runs unconditionally on every
|
|
242
|
+
* command and must feel instant, not stall an unrelated command behind a
|
|
243
|
+
* slow network. Pass explicitly to override.
|
|
220
244
|
* @returns {Promise<{flushed: number, remaining: number}|null>} null if skipped
|
|
221
245
|
* (empty queue or still cooling down) — distinguishes "nothing to report"
|
|
222
246
|
* from "attempted, flushed 0" for a caller that wants to print a summary.
|
|
@@ -227,15 +251,21 @@ export async function maybeAutoFlush({
|
|
|
227
251
|
now = () => Date.now(),
|
|
228
252
|
flushQueueFn = flushQueue,
|
|
229
253
|
timeoutMs,
|
|
254
|
+
getEffectiveRecallSettingsFn = getEffectiveRecallSettings,
|
|
230
255
|
} = {}) {
|
|
231
256
|
if (readQueue(configDir).length === 0) return null;
|
|
232
257
|
|
|
258
|
+
// Fetched once here (live) and threaded through to flushQueueFn below —
|
|
259
|
+
// flushQueue also needs max_entry_age_ms but must not fetch a second time
|
|
260
|
+
// for what is, from the outside, a single "attempt a flush" decision.
|
|
261
|
+
const settings = await getEffectiveRecallSettingsFn({ cliToken, configDir });
|
|
233
262
|
const lastAttemptAt = readLastFlushAttemptAt(configDir);
|
|
234
263
|
const nowMs = now();
|
|
235
|
-
if (lastAttemptAt && nowMs - new Date(lastAttemptAt).getTime() <
|
|
264
|
+
if (lastAttemptAt && nowMs - new Date(lastAttemptAt).getTime() < settings.flush_cooldown_ms) return null;
|
|
236
265
|
|
|
266
|
+
const effectiveTimeoutMs = timeoutMs !== undefined ? timeoutMs : settings.timeout_ms;
|
|
237
267
|
try {
|
|
238
|
-
return await flushQueueFn({ cliToken, configDir, now,
|
|
268
|
+
return await flushQueueFn({ cliToken, configDir, now, timeoutMs: effectiveTimeoutMs, settings });
|
|
239
269
|
} catch {
|
|
240
270
|
// A down backend or a thrown network error must never crash the command
|
|
241
271
|
// that opportunistically triggered this background attempt.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effective Recall queue settings (flush cooldown, per-request timeout, max
|
|
3
|
+
* queue size, max entry age) — manager-controlled per team via the Console
|
|
4
|
+
* (console/admin/recall).
|
|
5
|
+
*
|
|
6
|
+
* getEffectiveRecallSettings() fetches live at the moment a caller actually
|
|
7
|
+
* needs a decision (queue non-empty, a push just failed, or an explicit `tl
|
|
8
|
+
* recall settings`) — never on a background timer. Those moments are already
|
|
9
|
+
* rare (recall-queue.mjs's callers all short-circuit on a local, network-free
|
|
10
|
+
* read when there's nothing to do), so a manager's Console change is visible
|
|
11
|
+
* on the very next command that matters, without adding network cost to the
|
|
12
|
+
* overwhelming majority of commands that never touch this at all.
|
|
13
|
+
*
|
|
14
|
+
* A local cache (recall-settings-cache.json) exists only as an offline/
|
|
15
|
+
* failure fallback — written on every successful fetch, read when a fetch
|
|
16
|
+
* fails or there's no cliToken. Values from it are never trusted blindly,
|
|
17
|
+
* even though they came from our own backend: they're re-clamped against the
|
|
18
|
+
* same bounds the server enforces, so a corrupted cache file or a future
|
|
19
|
+
* server bug can never push a CLI-side value (e.g. a near-zero cooldown)
|
|
20
|
+
* outside safe range.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
26
|
+
import { apiBase } from './api-utils.mjs';
|
|
27
|
+
import { writeFileAtomically } from './recall-vault.mjs';
|
|
28
|
+
import { hashToken } from './recall-sync.mjs';
|
|
29
|
+
|
|
30
|
+
const SETTINGS_PATH = '/v1/recall/settings';
|
|
31
|
+
const CACHE_FILE = 'recall-settings-cache.json';
|
|
32
|
+
|
|
33
|
+
// Fixed, not manager-configurable — this timeout gates the fetch of the
|
|
34
|
+
// settings themselves, so it can't depend on a settings value that hasn't
|
|
35
|
+
// been fetched yet. Short: this fires only on already-rare paths, but must
|
|
36
|
+
// still never let a slow/down backend meaningfully stall those commands.
|
|
37
|
+
const FETCH_TIMEOUT_MS = 3_000;
|
|
38
|
+
|
|
39
|
+
// Platform defaults — must match RecallSettings::DEFAULTS in ticketlens-api.
|
|
40
|
+
export const DEFAULT_RECALL_SETTINGS = {
|
|
41
|
+
flush_cooldown_ms: 900_000, // 15 minutes
|
|
42
|
+
timeout_ms: 4_000, // 4 seconds
|
|
43
|
+
max_queue_size: 200,
|
|
44
|
+
max_entry_age_ms: 2_592_000_000, // 30 days
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Inclusive [min, max] bounds — must match RecallSettings::BOUNDS in ticketlens-api.
|
|
48
|
+
export const RECALL_SETTINGS_BOUNDS = {
|
|
49
|
+
flush_cooldown_ms: [60_000, 86_400_000], // 1m .. 24h
|
|
50
|
+
timeout_ms: [1_000, 30_000], // 1s .. 30s
|
|
51
|
+
max_queue_size: [10, 2_000],
|
|
52
|
+
max_entry_age_ms: [3_600_000, 7_776_000_000], // 1h .. 90d
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function cachePath(configDir) {
|
|
56
|
+
return path.join(configDir, CACHE_FILE);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clamp(field, value) {
|
|
60
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_RECALL_SETTINGS[field];
|
|
61
|
+
const [min, max] = RECALL_SETTINGS_BOUNDS[field];
|
|
62
|
+
return Math.min(Math.max(value, min), max);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Local-only, synchronous fallback — used when there's no cliToken, or a live
|
|
67
|
+
* fetch just failed. Requires cliToken to return a cached override at all: a
|
|
68
|
+
* cache written under a different (or since-logged-out) account must never
|
|
69
|
+
* silently apply to this one, so a tokenHash mismatch (or no token) falls
|
|
70
|
+
* through to platform defaults rather than trusting stale/foreign data.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} configDir
|
|
73
|
+
* @param {object} [opts]
|
|
74
|
+
* @param {string} [opts.cliToken]
|
|
75
|
+
* @returns {{flush_cooldown_ms: number, timeout_ms: number, max_queue_size: number, max_entry_age_ms: number}}
|
|
76
|
+
*/
|
|
77
|
+
export function readEffectiveRecallSettings(configDir = DEFAULT_CONFIG_DIR, { cliToken } = {}) {
|
|
78
|
+
if (!cliToken) return { ...DEFAULT_RECALL_SETTINGS };
|
|
79
|
+
|
|
80
|
+
let cached;
|
|
81
|
+
try {
|
|
82
|
+
cached = JSON.parse(fs.readFileSync(cachePath(configDir), 'utf8'));
|
|
83
|
+
} catch {
|
|
84
|
+
return { ...DEFAULT_RECALL_SETTINGS };
|
|
85
|
+
}
|
|
86
|
+
if (!cached || typeof cached.values !== 'object' || cached.tokenHash !== hashToken(cliToken)) {
|
|
87
|
+
return { ...DEFAULT_RECALL_SETTINGS };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const effective = {};
|
|
91
|
+
for (const field of Object.keys(DEFAULT_RECALL_SETTINGS)) {
|
|
92
|
+
effective[field] = clamp(field, cached.values[field]);
|
|
93
|
+
}
|
|
94
|
+
return effective;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* GET /v1/recall/settings. Returns {ok:false} on any network/parse failure —
|
|
99
|
+
* callers fall back to readEffectiveRecallSettings' cached/default behavior.
|
|
100
|
+
*/
|
|
101
|
+
export async function fetchRecallSettings({
|
|
102
|
+
cliToken,
|
|
103
|
+
timeoutMs = FETCH_TIMEOUT_MS,
|
|
104
|
+
fetcher = globalThis.fetch,
|
|
105
|
+
} = {}) {
|
|
106
|
+
if (!cliToken) return { ok: false, error: 'no-token' };
|
|
107
|
+
|
|
108
|
+
let res;
|
|
109
|
+
try {
|
|
110
|
+
res = await fetcher(`${apiBase()}${SETTINGS_PATH}`, {
|
|
111
|
+
headers: { Authorization: `Bearer ${cliToken}`, Accept: 'application/json' },
|
|
112
|
+
redirect: 'manual',
|
|
113
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
114
|
+
});
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return { ok: false, error: 'network', message: e.message };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!res.ok) return { ok: false, error: `http-${res.status}` };
|
|
120
|
+
|
|
121
|
+
let payload;
|
|
122
|
+
try {
|
|
123
|
+
payload = await res.json();
|
|
124
|
+
} catch {
|
|
125
|
+
return { ok: false, error: 'parse' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { ok: true, values: payload, isOverride: payload.is_override === true };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The main entry point — fetches live so a manager's Console change is
|
|
133
|
+
* visible immediately, falling back to the last-known-good local cache (or
|
|
134
|
+
* platform defaults) on any failure so a network blip never blocks a retry
|
|
135
|
+
* decision. Every successful fetch refreshes that fallback cache too, tagged
|
|
136
|
+
* with a hash of cliToken so a later account switch never serves a stale
|
|
137
|
+
* team's settings as a "fallback."
|
|
138
|
+
*
|
|
139
|
+
* @param {object} opts
|
|
140
|
+
* @param {string} [opts.cliToken]
|
|
141
|
+
* @param {string} [opts.configDir]
|
|
142
|
+
* @param {() => number} [opts.now]
|
|
143
|
+
* @param {Function} [opts.fetchRecallSettingsFn]
|
|
144
|
+
* @returns {Promise<{flush_cooldown_ms: number, timeout_ms: number, max_queue_size: number, max_entry_age_ms: number}>}
|
|
145
|
+
*/
|
|
146
|
+
export async function getEffectiveRecallSettings({
|
|
147
|
+
cliToken,
|
|
148
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
149
|
+
now = () => Date.now(),
|
|
150
|
+
fetchRecallSettingsFn = fetchRecallSettings,
|
|
151
|
+
} = {}) {
|
|
152
|
+
if (!cliToken) return readEffectiveRecallSettings(configDir);
|
|
153
|
+
|
|
154
|
+
const result = await fetchRecallSettingsFn({ cliToken });
|
|
155
|
+
if (!result.ok) return readEffectiveRecallSettings(configDir, { cliToken });
|
|
156
|
+
|
|
157
|
+
writeFileAtomically(cachePath(configDir), JSON.stringify({
|
|
158
|
+
values: result.values,
|
|
159
|
+
tokenHash: hashToken(cliToken),
|
|
160
|
+
fetchedAt: new Date(now()).toISOString(),
|
|
161
|
+
}));
|
|
162
|
+
|
|
163
|
+
const effective = {};
|
|
164
|
+
for (const field of Object.keys(DEFAULT_RECALL_SETTINGS)) {
|
|
165
|
+
effective[field] = clamp(field, result.values[field]);
|
|
166
|
+
}
|
|
167
|
+
return effective;
|
|
168
|
+
}
|