ticketlens 0.17.0 → 0.19.0
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 +4 -0
- package/bin/ticketlens.mjs +10 -0
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +3 -2
- package/skills/jtb/scripts/lib/help.mjs +5 -0
- package/skills/jtb/scripts/lib/note-command.mjs +11 -4
- package/skills/jtb/scripts/lib/recall-command.mjs +41 -0
- package/skills/jtb/scripts/lib/recall-queue.mjs +227 -0
- package/skills/jtb/scripts/lib/recall-sync.mjs +6 -2
- package/skills/jtb/scripts/lib/recall-vault.mjs +1 -1
- package/skills/jtb/scripts/lib/sync.mjs +1 -0
package/README.md
CHANGED
|
@@ -400,6 +400,8 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
400
400
|
|
|
401
401
|
**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 (cached 4h) before searching. 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.
|
|
402
402
|
|
|
403
|
+
**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 (at most once every 15 minutes, whenever `recall` or `note add` next talks to the network), 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. Queued entries expire after 30 days and are capped at 200; switching accounts never flushes a note under the wrong login.
|
|
404
|
+
|
|
403
405
|
`note add`'s save confirmation and `recall`'s search results are styled by default in a terminal; add `--plain` to either for bare, pipe-safe output. `recall` always shows each note's file ID (e.g. `[1784135399545-fe01c4.md]`) so you can open it directly (`cat ~/.ticketlens/recall/<PREFIX>/<id>`), or pass `--full` to print the full body content inline instead.
|
|
404
406
|
|
|
405
407
|
**Gaps** — every `ticketlens PROJ-123` brief also diffs the ticket's own description against its linked tickets (from the depth traversal you already requested) and its own downloaded attachments, looking for requirements mentioned there but missing here. Anything uncovered shows up under a `## Gaps` section, citing exactly where it came from — a linked ticket key or an attachment filename — as evidence, never an instruction to act on. Nothing is saved anywhere; it's recomputed fresh on every fetch. Requires a Pro license, same as Recall. No network call beyond what the brief already made.
|
|
@@ -674,6 +676,7 @@ ticketlens history <TICKET-KEY> # Show urgency timeline for a tick
|
|
|
674
676
|
echo "note body" | ticketlens note add --title="..." --ticket=CNV1-2 --tags=a,b # Save a note [Pro]
|
|
675
677
|
ticketlens recall CNV1-2 # Search saved notes by ticket key [Pro]
|
|
676
678
|
ticketlens recall "retry backoff" # Free-text search across all notes [Pro]
|
|
679
|
+
ticketlens recall sync # Retry any notes stuck in the local queue [Pro]
|
|
677
680
|
|
|
678
681
|
# ── Stats ──────────────────────────────────────────────────────────────────────
|
|
679
682
|
ticketlens stats # Response-time metrics from local history
|
|
@@ -752,6 +755,7 @@ ticketlens triage --digest # POST scored triage results to digest
|
|
|
752
755
|
ticketlens schedule # Set up a scheduled daily digest
|
|
753
756
|
ticketlens note add --title="..." # Save a Recall note (body from stdin)
|
|
754
757
|
ticketlens recall <query|TICKET-KEY> # Search your saved Recall notes
|
|
758
|
+
ticketlens recall sync # Retry any notes stuck in the local queue
|
|
755
759
|
ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
|
|
756
760
|
```
|
|
757
761
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -637,6 +637,16 @@ switch (command) {
|
|
|
637
637
|
|
|
638
638
|
case 'recall': {
|
|
639
639
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printRecallHelp(); break; }
|
|
640
|
+
if (cmdArgs[0] === 'sync') {
|
|
641
|
+
const { runRecallSync } = await import('../skills/jtb/scripts/lib/recall-command.mjs');
|
|
642
|
+
runRecallSync(cmdArgs.slice(1)).then(({ ok }) => {
|
|
643
|
+
if (!ok) process.exitCode = 1;
|
|
644
|
+
}).catch(err => {
|
|
645
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
646
|
+
process.exitCode = 1;
|
|
647
|
+
});
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
640
650
|
const { runRecall } = await import('../skills/jtb/scripts/lib/recall-command.mjs');
|
|
641
651
|
runRecall(cmdArgs).then(({ ok }) => {
|
|
642
652
|
if (!ok) process.exitCode = 1;
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.19.0 -->
|
|
2
2
|
---
|
|
3
3
|
name: jtb
|
|
4
4
|
description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
|
|
@@ -53,6 +53,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
53
53
|
/jtb cloud-keys timeout anthropic 15 # set per-request timeout in seconds
|
|
54
54
|
/jtb note "gotcha text" --ticket=PROD-1234 # save a Recall note (Pro)
|
|
55
55
|
/jtb recall PROD-1234 # search saved Recall notes (Pro)
|
|
56
|
+
/jtb recall sync # retry any notes stuck in the local queue (Pro)
|
|
56
57
|
```
|
|
57
58
|
|
|
58
59
|
## Prerequisites
|
|
@@ -241,7 +242,7 @@ This never calls any external API or bills any tokens beyond the session you alr
|
|
|
241
242
|
**Known limitation:** `note patch` only updates the local vault copy. If `note add` already pushed the original draft to a team (Team Recall enabled), a later refinement from this loop is *not* re-pushed — teammates who already pulled the note keep the original draft until this is addressed in a future iteration.
|
|
242
243
|
|
|
243
244
|
### Privacy
|
|
244
|
-
Recall notes are stored locally at `~/.ticketlens/recall/`. On a Free/Pro account with no Team Recall entitlement, they never leave the machine — no network calls. On a Team account with Recall enabled (owner-managed, may vary per user), notes also sync to the team's shared pool in the background so teammates can benefit from them too; a team manager reviews and verifies each incoming note before it's marked trusted.
|
|
245
|
+
Recall notes are stored locally at `~/.ticketlens/recall/`. On a Free/Pro account with no Team Recall entitlement, they never leave the machine — no network calls. On a Team account with Recall enabled (owner-managed, may vary per user), notes also sync to the team's shared pool in the background so teammates can benefit from them too; a team manager reviews and verifies each incoming note before it's marked trusted. If a team push fails for a transient reason (network error, timeout, 5xx), the note is queued locally and retried automatically in the background, or on demand with `ticketlens recall sync` — a session-expired or not-entitled push is never queued, since retrying those can't succeed without the user acting first.
|
|
245
246
|
|
|
246
247
|
---
|
|
247
248
|
|
|
@@ -536,12 +536,17 @@ export function printRecallHelp({ stream = process.stdout } = {}) {
|
|
|
536
536
|
` ${s.brand('--full')} Print each matching note's full body content`,
|
|
537
537
|
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
538
538
|
'',
|
|
539
|
+
` ${s.bold('COMMANDS')}`,
|
|
540
|
+
'',
|
|
541
|
+
` ${s.brand('sync')} Manually retry any team-synced notes stuck in the local retry queue ${s.dim('[Pro, requires login]')}`,
|
|
542
|
+
'',
|
|
539
543
|
` ${s.bold('EXAMPLES')}`,
|
|
540
544
|
'',
|
|
541
545
|
` ${s.dim('$')} ticketlens recall PROD-123`,
|
|
542
546
|
` ${s.dim('$')} ticketlens recall "retry backoff"`,
|
|
543
547
|
` ${s.dim('$')} ticketlens recall PROD-123 --plain`,
|
|
544
548
|
` ${s.dim('$')} ticketlens recall PROD-123 --full`,
|
|
549
|
+
` ${s.dim('$')} ticketlens recall sync`,
|
|
545
550
|
'',
|
|
546
551
|
];
|
|
547
552
|
stream.write(lines.join('\n') + '\n');
|
|
@@ -14,6 +14,7 @@ import { checkNoteStructure } from './note-structural-check.mjs';
|
|
|
14
14
|
import { writeNote, patchNoteBody } from './recall-vault.mjs';
|
|
15
15
|
import { readCliToken } from './cli-auth.mjs';
|
|
16
16
|
import { pushNote } from './recall-sync.mjs';
|
|
17
|
+
import { enqueueNote, isRetryableFailure, maybeAutoFlush } from './recall-queue.mjs';
|
|
17
18
|
import { incrementDraftKept, incrementDraftDeleted } from './activity-counter.mjs';
|
|
18
19
|
import { extractText } from './attachment-text.mjs';
|
|
19
20
|
import { TICKET_KEY_PATTERN } from './cli.mjs';
|
|
@@ -72,6 +73,9 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
72
73
|
writeNoteFn = writeNote,
|
|
73
74
|
readCliTokenFn = readCliToken,
|
|
74
75
|
pushNoteFn = pushNote,
|
|
76
|
+
enqueueNoteFn = enqueueNote,
|
|
77
|
+
isRetryableFailureFn = isRetryableFailure,
|
|
78
|
+
maybeAutoFlushFn = maybeAutoFlush,
|
|
75
79
|
incrementDraftKeptFn = incrementDraftKept,
|
|
76
80
|
incrementDraftDeletedFn = incrementDraftDeleted,
|
|
77
81
|
listAttachmentsFn = defaultListAttachments,
|
|
@@ -131,12 +135,15 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
131
135
|
|
|
132
136
|
const cliToken = readCliTokenFn(configDir);
|
|
133
137
|
if (cliToken) {
|
|
138
|
+
const warn = (s) => stream.write(s);
|
|
134
139
|
// Field names match PushRequest's validation rules (external_id, tickets) —
|
|
135
140
|
// the backend wire contract, not the local vault's internal camelCase shape.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
141
|
+
const payload = { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body };
|
|
142
|
+
const result = await pushNoteFn(payload, { cliToken, configDir, warn });
|
|
143
|
+
if (isRetryableFailureFn(result)) {
|
|
144
|
+
enqueueNoteFn(payload, { cliToken, configDir, warn });
|
|
145
|
+
}
|
|
146
|
+
await maybeAutoFlushFn({ cliToken, configDir });
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
return { written: true };
|
|
@@ -12,6 +12,7 @@ import { isLicensed, showUpgradePrompt } from './license.mjs';
|
|
|
12
12
|
import { listNotes } from './recall-vault.mjs';
|
|
13
13
|
import { readCliToken } from './cli-auth.mjs';
|
|
14
14
|
import { pullNotes } from './recall-sync.mjs';
|
|
15
|
+
import { maybeAutoFlush, flushQueue, readQueue } from './recall-queue.mjs';
|
|
15
16
|
import { styleRecallResults } from './styled-assembler.mjs';
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -26,6 +27,7 @@ export async function runRecall(cmdArgs, {
|
|
|
26
27
|
listNotesFn = listNotes,
|
|
27
28
|
readCliTokenFn = readCliToken,
|
|
28
29
|
pullNotesFn = pullNotes,
|
|
30
|
+
maybeAutoFlushFn = maybeAutoFlush,
|
|
29
31
|
} = {}) {
|
|
30
32
|
if (!isLicensedFn('pro', configDir)) {
|
|
31
33
|
showUpgradePrompt('pro', 'ticketlens recall', { stream: errorStream });
|
|
@@ -45,6 +47,7 @@ export async function runRecall(cmdArgs, {
|
|
|
45
47
|
configDir,
|
|
46
48
|
...(cmdArgs.includes('--no-cache') && { ttlMs: 0 }),
|
|
47
49
|
});
|
|
50
|
+
await maybeAutoFlushFn({ cliToken, configDir });
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
const filter = TICKET_KEY_PATTERN.test(arg) ? { ticketKey: arg } : { query: arg };
|
|
@@ -55,3 +58,41 @@ export async function runRecall(cmdArgs, {
|
|
|
55
58
|
stream.write(styleRecallResults(results, { styled, full }) + '\n');
|
|
56
59
|
return { ok: true };
|
|
57
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Implements `tl recall sync` — manually flushes the local retry queue
|
|
64
|
+
* (notes whose team push previously failed for a transient reason). Unlike
|
|
65
|
+
* the auto-flush attempted from `runRecall`/`note add`, this is an explicit
|
|
66
|
+
* user action, so failures are reported visibly rather than staying silent.
|
|
67
|
+
*
|
|
68
|
+
* @param {string[]} cmdArgs
|
|
69
|
+
* @returns {Promise<{ ok: boolean }>}
|
|
70
|
+
*/
|
|
71
|
+
export async function runRecallSync(cmdArgs, {
|
|
72
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
73
|
+
stream = process.stdout,
|
|
74
|
+
isLicensedFn = isLicensed,
|
|
75
|
+
readCliTokenFn = readCliToken,
|
|
76
|
+
readQueueFn = readQueue,
|
|
77
|
+
flushQueueFn = flushQueue,
|
|
78
|
+
} = {}) {
|
|
79
|
+
if (!isLicensedFn('pro', configDir)) {
|
|
80
|
+
showUpgradePrompt('pro', 'ticketlens recall', { stream });
|
|
81
|
+
return { ok: false };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const cliToken = readCliTokenFn(configDir);
|
|
85
|
+
if (!cliToken) {
|
|
86
|
+
stream.write('Not logged in — run `ticketlens login` first.\n');
|
|
87
|
+
return { ok: false };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (readQueueFn(configDir).length === 0) {
|
|
91
|
+
stream.write('Nothing to sync.\n');
|
|
92
|
+
return { ok: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { flushed, remaining } = await flushQueueFn({ cliToken, configDir, warn: (s) => stream.write(s) });
|
|
96
|
+
stream.write(`Synced ${flushed} note(s). ${remaining} still pending.\n`);
|
|
97
|
+
return { ok: true };
|
|
98
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local retry queue for Recall notes whose push to the team backend failed
|
|
3
|
+
* for a transient reason (network error, timeout, or 5xx). A note is never
|
|
4
|
+
* lost — it's already safe in the local vault (recall-vault.mjs) before this
|
|
5
|
+
* module ever sees it; this only tracks the separate, best-effort intent to
|
|
6
|
+
* also sync it to the team.
|
|
7
|
+
*
|
|
8
|
+
* Retry classification happens once, at the moment the original push fails
|
|
9
|
+
* (isRetryableFailure) — 401/403/other-4xx are deliberately excluded: a
|
|
10
|
+
* stale session or a doomed payload will never succeed by retrying, and
|
|
11
|
+
* pushNote already warns the user about those synchronously.
|
|
12
|
+
*
|
|
13
|
+
* Growth is bounded two independent ways: a hard cap on entry count, and an
|
|
14
|
+
* age-based expiry keyed off firstQueuedAt (not failedAt, which refreshes on
|
|
15
|
+
* every retry attempt and would otherwise let a perpetually-failing entry
|
|
16
|
+
* live forever).
|
|
17
|
+
*
|
|
18
|
+
* enqueueNote/flushQueue do read-modify-write on the queue file with no file
|
|
19
|
+
* lock — same tradeoff already accepted by recall-pull-state.json and
|
|
20
|
+
* recall-entitlement-state.json in recall-sync.mjs. Two concurrent CLI
|
|
21
|
+
* invocations racing this file can lose one writer's update. Bounded,
|
|
22
|
+
* accepted risk: this queue is a retry-intent cache, not the source of truth
|
|
23
|
+
* (the note itself is already safe in the vault before it ever reaches here)
|
|
24
|
+
* — not worth a lock for a low-frequency, single-user CLI tool.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
30
|
+
import { writeFileAtomically } from './recall-vault.mjs';
|
|
31
|
+
import { pushNote, hashToken } from './recall-sync.mjs';
|
|
32
|
+
|
|
33
|
+
const QUEUE_FILE = 'recall-pending.json';
|
|
34
|
+
const FLUSH_STATE_FILE = 'recall-flush-state.json';
|
|
35
|
+
|
|
36
|
+
export const MAX_QUEUE_SIZE = 200;
|
|
37
|
+
export const MAX_ENTRY_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
38
|
+
export const AUTO_FLUSH_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
|
|
39
|
+
|
|
40
|
+
function queuePath(configDir) {
|
|
41
|
+
return path.join(configDir, QUEUE_FILE);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function flushStatePath(configDir) {
|
|
45
|
+
return path.join(configDir, FLUSH_STATE_FILE);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} configDir
|
|
50
|
+
* @returns {Array<{id: string, notePayload: object, tokenHash: string, firstQueuedAt: string, failedAt: string, attempts: number}>}
|
|
51
|
+
*/
|
|
52
|
+
export function readQueue(configDir) {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(fs.readFileSync(queuePath(configDir), 'utf8'));
|
|
55
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeQueue(configDir, entries) {
|
|
62
|
+
writeFileAtomically(queuePath(configDir), JSON.stringify(entries));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function purgeExpired(entries, now) {
|
|
66
|
+
return entries.filter(entry => now - new Date(entry.firstQueuedAt).getTime() <= MAX_ENTRY_AGE_MS);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Decides whether a failed push is worth retrying later. Network errors and
|
|
71
|
+
* timeouts (pushNote returns no `status` for these) and 5xx responses are
|
|
72
|
+
* transient. 401 (session expired), 403 (not entitled / no team), and any
|
|
73
|
+
* other 4xx (e.g. a validation failure) are not — retrying cannot fix them
|
|
74
|
+
* without the user acting, or would just retry a payload that will never be
|
|
75
|
+
* accepted.
|
|
76
|
+
*
|
|
77
|
+
* @param {{ ok: boolean, status?: number }} result
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
export function isRetryableFailure(result) {
|
|
81
|
+
return !result.ok && (result.status === undefined || result.status >= 500);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Queues a note for later retry after a transient push failure. Purges
|
|
86
|
+
* expired entries first, then evicts the oldest entry (with a single warn)
|
|
87
|
+
* if appending would exceed MAX_QUEUE_SIZE.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} notePayload - exact wire payload passed to pushNote
|
|
90
|
+
* @param {object} opts
|
|
91
|
+
* @param {string} opts.cliToken
|
|
92
|
+
* @param {string} [opts.configDir]
|
|
93
|
+
* @param {() => number} [opts.now]
|
|
94
|
+
* @param {Function} [opts.warn]
|
|
95
|
+
*/
|
|
96
|
+
export function enqueueNote(notePayload, {
|
|
97
|
+
cliToken,
|
|
98
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
99
|
+
now = () => Date.now(),
|
|
100
|
+
warn = (s) => process.stderr.write(s),
|
|
101
|
+
} = {}) {
|
|
102
|
+
const nowMs = now();
|
|
103
|
+
let entries = purgeExpired(readQueue(configDir), nowMs);
|
|
104
|
+
|
|
105
|
+
if (entries.length >= MAX_QUEUE_SIZE) {
|
|
106
|
+
entries = entries.slice(1);
|
|
107
|
+
warn(' Recall queue full — dropped the oldest queued note to make room.\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
111
|
+
entries.push({
|
|
112
|
+
id: notePayload.external_id,
|
|
113
|
+
notePayload,
|
|
114
|
+
tokenHash: hashToken(cliToken),
|
|
115
|
+
firstQueuedAt: nowIso,
|
|
116
|
+
failedAt: nowIso,
|
|
117
|
+
attempts: 0,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
writeQueue(configDir, entries);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Attempts to push every queued entry belonging to the current account
|
|
125
|
+
* (matched by tokenHash). Entries queued under a different account are left
|
|
126
|
+
* untouched — never attempted, never evicted by this pass. Expired entries
|
|
127
|
+
* are purged first, regardless of tokenHash.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} opts
|
|
130
|
+
* @param {string} opts.cliToken
|
|
131
|
+
* @param {string} [opts.configDir]
|
|
132
|
+
* @param {Function} [opts.pushNoteFn]
|
|
133
|
+
* @param {Function} [opts.warn] - defaults to silent; callers doing a visible/manual sync should pass a real one
|
|
134
|
+
* @param {() => number} [opts.now]
|
|
135
|
+
* @returns {Promise<{ flushed: number, remaining: number }>}
|
|
136
|
+
*/
|
|
137
|
+
export async function flushQueue({
|
|
138
|
+
cliToken,
|
|
139
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
140
|
+
pushNoteFn = pushNote,
|
|
141
|
+
isRetryableFailureFn = isRetryableFailure,
|
|
142
|
+
warn = () => {},
|
|
143
|
+
now = () => Date.now(),
|
|
144
|
+
} = {}) {
|
|
145
|
+
const nowMs = now();
|
|
146
|
+
const currentHash = hashToken(cliToken);
|
|
147
|
+
const entries = purgeExpired(readQueue(configDir), nowMs);
|
|
148
|
+
|
|
149
|
+
let flushed = 0;
|
|
150
|
+
const remaining = [];
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
if (entry.tokenHash !== currentHash) {
|
|
153
|
+
remaining.push(entry);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const result = await pushNoteFn(entry.notePayload, { cliToken, configDir, warn });
|
|
158
|
+
if (result.ok) {
|
|
159
|
+
flushed++;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A retry can surface a DIFFERENT failure than the one that originally
|
|
164
|
+
// queued this entry (e.g. the session expired between enqueue and this
|
|
165
|
+
// attempt) — reclassify every time rather than trusting the original
|
|
166
|
+
// enqueue decision, so a now-unrecoverable entry is dropped immediately
|
|
167
|
+
// instead of silently retrying for up to MAX_ENTRY_AGE_MS.
|
|
168
|
+
if (!isRetryableFailureFn(result)) continue;
|
|
169
|
+
|
|
170
|
+
remaining.push({ ...entry, attempts: entry.attempts + 1, failedAt: new Date(nowMs).toISOString() });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
writeQueue(configDir, remaining);
|
|
174
|
+
return { flushed, remaining: remaining.length };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readLastFlushAttemptAt(configDir) {
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(fs.readFileSync(flushStatePath(configDir), 'utf8')).lastAttemptAt ?? null;
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function writeLastFlushAttemptAt(configDir, isoTimestamp) {
|
|
186
|
+
try {
|
|
187
|
+
fs.writeFileSync(flushStatePath(configDir), JSON.stringify({ lastAttemptAt: isoTimestamp }), 'utf8');
|
|
188
|
+
} catch {
|
|
189
|
+
// Non-fatal — worst case the next command re-checks a moment sooner than the interval intends.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Time-gated background flush, attempted from the CLI's existing
|
|
195
|
+
* Recall-touching entry points (note add's push, recall's pull) rather than
|
|
196
|
+
* on every invocation — a no-op unless the queue is non-empty AND at least
|
|
197
|
+
* AUTO_FLUSH_INTERVAL_MS has passed since the last attempt. The attempt
|
|
198
|
+
* timestamp is recorded even on failure, so a down backend can't be hammered
|
|
199
|
+
* once per command within the window.
|
|
200
|
+
*
|
|
201
|
+
* @param {object} opts
|
|
202
|
+
* @param {string} opts.cliToken
|
|
203
|
+
* @param {string} [opts.configDir]
|
|
204
|
+
* @param {() => number} [opts.now]
|
|
205
|
+
* @param {Function} [opts.flushQueueFn]
|
|
206
|
+
*/
|
|
207
|
+
export async function maybeAutoFlush({
|
|
208
|
+
cliToken,
|
|
209
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
210
|
+
now = () => Date.now(),
|
|
211
|
+
flushQueueFn = flushQueue,
|
|
212
|
+
} = {}) {
|
|
213
|
+
if (readQueue(configDir).length === 0) return;
|
|
214
|
+
|
|
215
|
+
const lastAttemptAt = readLastFlushAttemptAt(configDir);
|
|
216
|
+
const nowMs = now();
|
|
217
|
+
if (lastAttemptAt && nowMs - new Date(lastAttemptAt).getTime() < AUTO_FLUSH_INTERVAL_MS) return;
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
await flushQueueFn({ cliToken, configDir, now });
|
|
221
|
+
} catch {
|
|
222
|
+
// A down backend or a thrown network error must never crash the command
|
|
223
|
+
// that opportunistically triggered this background attempt.
|
|
224
|
+
} finally {
|
|
225
|
+
writeLastFlushAttemptAt(configDir, new Date(nowMs).toISOString());
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -56,7 +56,7 @@ function entitlementCachePath(configDir) {
|
|
|
56
56
|
// switch (e.g. login as someone else) so a stale cache never suppresses a
|
|
57
57
|
// different account's push. Found via Local Live Test: configDir alone is
|
|
58
58
|
// not a valid cache key — cli-token.json can change without configDir changing.
|
|
59
|
-
function hashToken(cliToken) {
|
|
59
|
+
export function hashToken(cliToken) {
|
|
60
60
|
return createHash('sha256').update(cliToken).digest('hex');
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -107,7 +107,11 @@ export async function pushNote(note, {
|
|
|
107
107
|
// flips the Recall grant — without this, the warning below repeats forever.
|
|
108
108
|
const checkedAt = readEntitlementCheckedAt(configDir, cliToken);
|
|
109
109
|
if (checkedAt && now() - new Date(checkedAt).getTime() < ttlMs) {
|
|
110
|
-
|
|
110
|
+
// status: 403 — this is a cached replay of the entitlement 403 below, not a
|
|
111
|
+
// fresh network outcome. Without it, isRetryableFailure's "no status means
|
|
112
|
+
// network error" rule misclassifies this as transient and queues a note
|
|
113
|
+
// that can never succeed until the owner grants entitlement.
|
|
114
|
+
return { ok: false, status: 403, skipped: true };
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
warnIfInsecure(apiBase(), warn);
|
|
@@ -49,7 +49,7 @@ function generateNoteId() {
|
|
|
49
49
|
return `${Date.now()}-${randomBytes(3).toString('hex')}.md`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function writeFileAtomically(filePath, contents) {
|
|
52
|
+
export function writeFileAtomically(filePath, contents) {
|
|
53
53
|
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
54
54
|
fs.writeFileSync(tmpPath, contents, 'utf8');
|
|
55
55
|
fs.renameSync(tmpPath, filePath);
|
|
@@ -34,6 +34,7 @@ export function serverToCliProfile(serverProfile) {
|
|
|
34
34
|
...(p.project_paths?.length ? { projectPaths: p.project_paths } : {}),
|
|
35
35
|
...(p.triage_statuses?.length ? { triageStatuses: p.triage_statuses } : {}),
|
|
36
36
|
...(p.stale_rule ? { staleRule: p.stale_rule } : {}),
|
|
37
|
+
...(p.attention_rules?.length ? { attentionRules: p.attention_rules } : {}),
|
|
37
38
|
...(p.known_statuses?.length ? { knownStatuses: p.known_statuses, statusesCachedAt: p.statuses_cached_at ?? null } : {}),
|
|
38
39
|
};
|
|
39
40
|
return { name: p.name, profileData };
|