@soundbi/sound-connect 0.1.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 +111 -0
- package/dist/__tests__/ingest.test.d.ts +18 -0
- package/dist/__tests__/ingest.test.d.ts.map +1 -0
- package/dist/__tests__/ingest.test.js +639 -0
- package/dist/__tests__/ingest.test.js.map +1 -0
- package/dist/__tests__/isolation.test.d.ts +12 -0
- package/dist/__tests__/isolation.test.d.ts.map +1 -0
- package/dist/__tests__/isolation.test.js +149 -0
- package/dist/__tests__/isolation.test.js.map +1 -0
- package/dist/__tests__/retry-queue.test.d.ts +11 -0
- package/dist/__tests__/retry-queue.test.d.ts.map +1 -0
- package/dist/__tests__/retry-queue.test.js +458 -0
- package/dist/__tests__/retry-queue.test.js.map +1 -0
- package/dist/auth.d.ts +80 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +211 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +66 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +100 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest.d.ts +253 -0
- package/dist/ingest.d.ts.map +1 -0
- package/dist/ingest.js +573 -0
- package/dist/ingest.js.map +1 -0
- package/dist/proxy.d.ts +79 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +217 -0
- package/dist/proxy.js.map +1 -0
- package/dist/retry-queue.d.ts +236 -0
- package/dist/retry-queue.d.ts.map +1 -0
- package/dist/retry-queue.js +461 -0
- package/dist/retry-queue.js.map +1 -0
- package/dist/tools.d.ts +75 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +368 -0
- package/dist/tools.js.map +1 -0
- package/package.json +36 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local ingest retry queue — STORY-013, ADR-011.
|
|
3
|
+
*
|
|
4
|
+
* When the Sound Connect backend is briefly unreachable (network error or 5xx),
|
|
5
|
+
* failed /ingest POST chunks are written to a local file-based queue so they are
|
|
6
|
+
* not silently lost. A background retry worker drains the queue with exponential
|
|
7
|
+
* backoff; the ADR-004 sha256 idempotency key makes re-sends safe.
|
|
8
|
+
*
|
|
9
|
+
* Queue state machine per item:
|
|
10
|
+
* pending → retry attempts < MAX_RETRY_ATTEMPTS and no permanent 4xx failure
|
|
11
|
+
* dead → 4xx response from backend (auth/permission error — do not retry)
|
|
12
|
+
*
|
|
13
|
+
* Queue storage: OS temp dir / sound-connect-queue / <clientSlug> /
|
|
14
|
+
* Each item is a JSON file: <enqueuedAt>-<sha256Prefix>.json
|
|
15
|
+
* A dead item is renamed to: <name>.dead
|
|
16
|
+
*
|
|
17
|
+
* Restart resilience: items survive a bridge restart because they live on disk.
|
|
18
|
+
* On startup, call loadQueue() to discover and resume pending items.
|
|
19
|
+
*
|
|
20
|
+
* Permanent failures (4xx): written to disk as .dead files and surfaced via
|
|
21
|
+
* ingest_status so the user can see them. They are never silently retried.
|
|
22
|
+
*
|
|
23
|
+
* ADR-011: fail closed on auth (4xx → dead, not retried), safe retry on transient
|
|
24
|
+
* outages, idempotency from ADR-004 makes retries harmless.
|
|
25
|
+
*/
|
|
26
|
+
import { writeFile, readFile, readdir, rm, mkdir } from 'node:fs/promises';
|
|
27
|
+
import { join, basename } from 'node:path';
|
|
28
|
+
import { tmpdir } from 'node:os';
|
|
29
|
+
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
30
|
+
/** Maximum number of retry attempts before giving up (and marking dead). */
|
|
31
|
+
export const MAX_RETRY_ATTEMPTS = 8;
|
|
32
|
+
/** Base delay in milliseconds for exponential backoff (doubles each attempt). */
|
|
33
|
+
export const BACKOFF_BASE_MS = 1_000;
|
|
34
|
+
/** Maximum backoff cap in milliseconds (~2 minutes). */
|
|
35
|
+
export const BACKOFF_MAX_MS = 120_000;
|
|
36
|
+
/** Interval in milliseconds between retry worker sweeps. */
|
|
37
|
+
export const WORKER_INTERVAL_MS = 10_000;
|
|
38
|
+
// ── Queue directory ────────────────────────────────────────────────────────────
|
|
39
|
+
/**
|
|
40
|
+
* Returns the absolute path to the queue directory for a given client slug.
|
|
41
|
+
* Uses OS temp dir so the queue is writable without special permissions.
|
|
42
|
+
* Items survive bridge restarts as long as the OS does not clean temp on reboot.
|
|
43
|
+
*
|
|
44
|
+
* Overridable via SC_QUEUE_DIR env var for tests.
|
|
45
|
+
*/
|
|
46
|
+
export function queueDir(clientSlug) {
|
|
47
|
+
const base = process.env['SC_QUEUE_DIR'] ?? join(tmpdir(), 'sound-connect-queue');
|
|
48
|
+
return join(base, clientSlug);
|
|
49
|
+
}
|
|
50
|
+
/** Ensures the queue directory exists (creates it if absent). */
|
|
51
|
+
async function ensureQueueDir(dir) {
|
|
52
|
+
await mkdir(dir, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
// ── Item serialization ─────────────────────────────────────────────────────────
|
|
55
|
+
/** Returns the filename (not path) for a pending queue item. */
|
|
56
|
+
function pendingFilename(item) {
|
|
57
|
+
return `${item.id}.json`;
|
|
58
|
+
}
|
|
59
|
+
/** Returns the filename (not path) for a dead queue item. */
|
|
60
|
+
function deadFilename(item) {
|
|
61
|
+
return `${item.id}.dead`;
|
|
62
|
+
}
|
|
63
|
+
// ── Write item to disk ─────────────────────────────────────────────────────────
|
|
64
|
+
/**
|
|
65
|
+
* Enqueue a failed ingest chunk to the local retry queue.
|
|
66
|
+
*
|
|
67
|
+
* Writes a JSON file to the queue directory. The file is named by a unique ID
|
|
68
|
+
* derived from the enqueue time and the chunk's sha256 prefix (stable, not random —
|
|
69
|
+
* so if the same chunk is enqueued twice due to a crash, we don't duplicate items;
|
|
70
|
+
* but the timestamp disambiguates distinct enqueue events for the same content).
|
|
71
|
+
*
|
|
72
|
+
* @param backendUrl Backend URL that was unreachable.
|
|
73
|
+
* @param clientSlug Bound client slug.
|
|
74
|
+
* @param payload The ingest payload that failed.
|
|
75
|
+
* @param lastError The error message from the failed attempt.
|
|
76
|
+
* @returns The created QueueItem.
|
|
77
|
+
*/
|
|
78
|
+
export async function enqueueFailedChunk(backendUrl, clientSlug, payload, lastError) {
|
|
79
|
+
const now = new Date().toISOString();
|
|
80
|
+
const sha256Prefix = payload.sha256.slice(0, 12);
|
|
81
|
+
const id = `${Date.now()}-${sha256Prefix}`;
|
|
82
|
+
const item = {
|
|
83
|
+
id,
|
|
84
|
+
backendUrl,
|
|
85
|
+
clientSlug,
|
|
86
|
+
enqueuedAt: now,
|
|
87
|
+
attempts: 0,
|
|
88
|
+
lastError,
|
|
89
|
+
payload,
|
|
90
|
+
};
|
|
91
|
+
const dir = queueDir(clientSlug);
|
|
92
|
+
await ensureQueueDir(dir);
|
|
93
|
+
await writeFile(join(dir, pendingFilename(item)), JSON.stringify(item, null, 2), 'utf8');
|
|
94
|
+
return item;
|
|
95
|
+
}
|
|
96
|
+
// ── Load queue from disk ───────────────────────────────────────────────────────
|
|
97
|
+
/**
|
|
98
|
+
* Load all pending queue items from disk.
|
|
99
|
+
*
|
|
100
|
+
* Reads all .json files in the queue directory. Dead (.dead) files are NOT
|
|
101
|
+
* returned here — use loadDeadItems() for those.
|
|
102
|
+
*
|
|
103
|
+
* @param clientSlug Bound client slug.
|
|
104
|
+
* @returns Array of pending QueueItems, sorted by enqueuedAt ascending.
|
|
105
|
+
*/
|
|
106
|
+
export async function loadPendingItems(clientSlug) {
|
|
107
|
+
const dir = queueDir(clientSlug);
|
|
108
|
+
await ensureQueueDir(dir);
|
|
109
|
+
let entries;
|
|
110
|
+
try {
|
|
111
|
+
entries = await readdir(dir);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
const items = [];
|
|
117
|
+
for (const name of entries) {
|
|
118
|
+
if (!name.endsWith('.json'))
|
|
119
|
+
continue;
|
|
120
|
+
try {
|
|
121
|
+
const text = await readFile(join(dir, name), 'utf8');
|
|
122
|
+
const item = JSON.parse(text);
|
|
123
|
+
items.push(item);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Corrupted file — skip silently (do not crash the worker).
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Oldest first.
|
|
130
|
+
items.sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt));
|
|
131
|
+
return items;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Load all dead items from disk.
|
|
135
|
+
*
|
|
136
|
+
* Dead items are .dead files in the queue directory (4xx permanent failures).
|
|
137
|
+
*
|
|
138
|
+
* @param clientSlug Bound client slug.
|
|
139
|
+
* @returns Array of dead QueueItems.
|
|
140
|
+
*/
|
|
141
|
+
export async function loadDeadItems(clientSlug) {
|
|
142
|
+
const dir = queueDir(clientSlug);
|
|
143
|
+
await ensureQueueDir(dir);
|
|
144
|
+
let entries;
|
|
145
|
+
try {
|
|
146
|
+
entries = await readdir(dir);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
const items = [];
|
|
152
|
+
for (const name of entries) {
|
|
153
|
+
if (!name.endsWith('.dead'))
|
|
154
|
+
continue;
|
|
155
|
+
try {
|
|
156
|
+
const text = await readFile(join(dir, name), 'utf8');
|
|
157
|
+
const item = JSON.parse(text);
|
|
158
|
+
items.push(item);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Corrupted dead file — skip.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
items.sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt));
|
|
165
|
+
return items;
|
|
166
|
+
}
|
|
167
|
+
// ── Queue status (for ingest_status tool) ─────────────────────────────────────
|
|
168
|
+
/**
|
|
169
|
+
* Returns a summary of the current queue state for the `ingest_status` MCP tool.
|
|
170
|
+
*
|
|
171
|
+
* STORY-013 AC3: reports pending/failed queue depth.
|
|
172
|
+
*
|
|
173
|
+
* @param clientSlug Bound client slug.
|
|
174
|
+
*/
|
|
175
|
+
export async function getQueueStatus(clientSlug) {
|
|
176
|
+
const [pending, dead] = await Promise.all([
|
|
177
|
+
loadPendingItems(clientSlug),
|
|
178
|
+
loadDeadItems(clientSlug),
|
|
179
|
+
]);
|
|
180
|
+
return {
|
|
181
|
+
pending: pending.length,
|
|
182
|
+
dead: dead.length,
|
|
183
|
+
deadItems: dead.map(item => ({
|
|
184
|
+
id: item.id,
|
|
185
|
+
filename: item.payload.filename,
|
|
186
|
+
enqueuedAt: item.enqueuedAt,
|
|
187
|
+
lastError: item.lastError ?? '(unknown error)',
|
|
188
|
+
})),
|
|
189
|
+
pendingItems: pending.map(item => ({
|
|
190
|
+
id: item.id,
|
|
191
|
+
filename: item.payload.filename,
|
|
192
|
+
enqueuedAt: item.enqueuedAt,
|
|
193
|
+
attempts: item.attempts,
|
|
194
|
+
lastError: item.lastError,
|
|
195
|
+
})),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
// ── Retry worker helpers ───────────────────────────────────────────────────────
|
|
199
|
+
/**
|
|
200
|
+
* Compute the exponential backoff delay for a given attempt number.
|
|
201
|
+
*
|
|
202
|
+
* Attempt 1 → BACKOFF_BASE_MS (1s)
|
|
203
|
+
* Attempt 2 → 2s
|
|
204
|
+
* Attempt 3 → 4s
|
|
205
|
+
* ...capped at BACKOFF_MAX_MS.
|
|
206
|
+
*/
|
|
207
|
+
export function backoffMs(attempt) {
|
|
208
|
+
return Math.min(BACKOFF_BASE_MS * Math.pow(2, attempt - 1), BACKOFF_MAX_MS);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Returns true if an item is ready to retry based on its attempt count and the
|
|
212
|
+
* time of the last attempt.
|
|
213
|
+
*
|
|
214
|
+
* An item with 0 attempts is always ready (never tried yet after enqueue).
|
|
215
|
+
* An item that has been attempted is ready once the backoff window has elapsed.
|
|
216
|
+
*/
|
|
217
|
+
export function isReadyToRetry(item, now = new Date()) {
|
|
218
|
+
if (item.attempts === 0)
|
|
219
|
+
return true;
|
|
220
|
+
if (!item.lastAttemptAt)
|
|
221
|
+
return true;
|
|
222
|
+
const lastAttempt = new Date(item.lastAttemptAt).getTime();
|
|
223
|
+
const delay = backoffMs(item.attempts);
|
|
224
|
+
return now.getTime() - lastAttempt >= delay;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Attempt to POST a queued ingest chunk to the backend.
|
|
228
|
+
*
|
|
229
|
+
* Distinguishes:
|
|
230
|
+
* - success (2xx) → remove from queue
|
|
231
|
+
* - transient (network / 5xx) → increment attempts, retry later
|
|
232
|
+
* - permanent (4xx) → mark dead, surface to user (ADR-011)
|
|
233
|
+
*
|
|
234
|
+
* @param item The queue item to retry.
|
|
235
|
+
* @param token Current bearer token (acquired silently before calling).
|
|
236
|
+
*/
|
|
237
|
+
export async function attemptRetry(item, token) {
|
|
238
|
+
const { backendUrl, clientSlug, payload } = item;
|
|
239
|
+
const url = `${backendUrl}/ingest/${encodeURIComponent(clientSlug)}`;
|
|
240
|
+
let res;
|
|
241
|
+
try {
|
|
242
|
+
res = await fetch(url, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: {
|
|
245
|
+
'Content-Type': 'application/json',
|
|
246
|
+
'Authorization': `Bearer ${token}`,
|
|
247
|
+
},
|
|
248
|
+
body: JSON.stringify(payload),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
// Network-level failure (DNS, timeout, ECONNREFUSED) — transient.
|
|
253
|
+
return {
|
|
254
|
+
outcome: 'transient',
|
|
255
|
+
error: `Network error: ${err.message}`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
// 4xx responses are permanent (auth/access errors — retrying won't help).
|
|
259
|
+
// ADR-011: these go to dead, not silently retried forever.
|
|
260
|
+
if (res.status >= 400 && res.status < 500) {
|
|
261
|
+
let hint = '';
|
|
262
|
+
try {
|
|
263
|
+
const bodyText = await res.text();
|
|
264
|
+
const parsed = JSON.parse(bodyText);
|
|
265
|
+
hint = (parsed['hint'] ?? parsed['error'] ?? parsed['message'] ?? '');
|
|
266
|
+
}
|
|
267
|
+
catch { /* ignore */ }
|
|
268
|
+
const errorMsg = res.status === 401
|
|
269
|
+
? 'Bearer token rejected (401). Run `npx @soundbi/sound-connect login` to re-authenticate.'
|
|
270
|
+
: res.status === 403
|
|
271
|
+
? `Access denied to client "${clientSlug}" (403). ${hint || 'Verify your Sound Connect membership.'}`
|
|
272
|
+
: `Permanent error HTTP ${res.status}${hint ? `: ${hint}` : ''}.`;
|
|
273
|
+
return { outcome: 'permanent', error: errorMsg };
|
|
274
|
+
}
|
|
275
|
+
// 5xx or other non-2xx — transient.
|
|
276
|
+
if (!res.ok) {
|
|
277
|
+
let bodySnippet = '';
|
|
278
|
+
try {
|
|
279
|
+
bodySnippet = (await res.text()).slice(0, 120);
|
|
280
|
+
}
|
|
281
|
+
catch { /* ignore */ }
|
|
282
|
+
return {
|
|
283
|
+
outcome: 'transient',
|
|
284
|
+
error: `Backend HTTP ${res.status}${bodySnippet ? `: ${bodySnippet}` : ''}`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
// 2xx success — check for deduplication signal.
|
|
288
|
+
let deduped = false;
|
|
289
|
+
try {
|
|
290
|
+
const bodyText = await res.text();
|
|
291
|
+
const parsed = JSON.parse(bodyText);
|
|
292
|
+
deduped = parsed['deduped'] === true;
|
|
293
|
+
}
|
|
294
|
+
catch { /* ignore */ }
|
|
295
|
+
return { outcome: 'success', deduped };
|
|
296
|
+
}
|
|
297
|
+
// ── Item lifecycle helpers ─────────────────────────────────────────────────────
|
|
298
|
+
/** Update the item on disk after an attempt (increments attempts, records error). */
|
|
299
|
+
async function persistUpdatedItem(item) {
|
|
300
|
+
const dir = queueDir(item.clientSlug);
|
|
301
|
+
await writeFile(join(dir, pendingFilename(item)), JSON.stringify(item, null, 2), 'utf8');
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Move an item to dead status (.json → .dead).
|
|
305
|
+
*
|
|
306
|
+
* Writes the CURRENT item state (including updated lastError) to the .dead file,
|
|
307
|
+
* then removes the .json file. This ensures the dead file reflects the final error
|
|
308
|
+
* from the attempt that marked it dead, not the original enqueue error.
|
|
309
|
+
*/
|
|
310
|
+
async function markItemDead(item) {
|
|
311
|
+
const dir = queueDir(item.clientSlug);
|
|
312
|
+
const src = join(dir, pendingFilename(item));
|
|
313
|
+
const dst = join(dir, deadFilename(item));
|
|
314
|
+
// Write updated state to .dead first (ensures lastError is current).
|
|
315
|
+
await writeFile(dst, JSON.stringify(item, null, 2), 'utf8');
|
|
316
|
+
// Remove the .json file (best-effort — if it's already gone, that's fine).
|
|
317
|
+
await rm(src, { force: true });
|
|
318
|
+
}
|
|
319
|
+
/** Remove a successfully-delivered item from disk. */
|
|
320
|
+
async function removeSuccessfulItem(item) {
|
|
321
|
+
const dir = queueDir(item.clientSlug);
|
|
322
|
+
await rm(join(dir, pendingFilename(item)), { force: true });
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Run one sweep of the retry worker:
|
|
326
|
+
* 1. Load all pending items.
|
|
327
|
+
* 2. For each item that is ready to retry (backoff elapsed):
|
|
328
|
+
* a. Acquire a fresh token (silent).
|
|
329
|
+
* b. Attempt the POST.
|
|
330
|
+
* c. On success → remove from disk.
|
|
331
|
+
* d. On transient → increment attempts; if exhausted → mark dead.
|
|
332
|
+
* e. On permanent → mark dead.
|
|
333
|
+
*
|
|
334
|
+
* @param clientSlug Bound client slug.
|
|
335
|
+
* @param tokenProvider Async function that returns a fresh access token or null.
|
|
336
|
+
* @returns Summary of this sweep.
|
|
337
|
+
*/
|
|
338
|
+
export async function runRetrySweep(clientSlug, tokenProvider) {
|
|
339
|
+
const items = await loadPendingItems(clientSlug);
|
|
340
|
+
const now = new Date();
|
|
341
|
+
let delivered = 0;
|
|
342
|
+
let exhausted = 0;
|
|
343
|
+
let skipped = 0;
|
|
344
|
+
let tokenMissing = false;
|
|
345
|
+
for (const item of items) {
|
|
346
|
+
if (!isReadyToRetry(item, now)) {
|
|
347
|
+
skipped++;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
// Acquire a fresh token before each item (MSAL handles silent refresh).
|
|
351
|
+
const token = await tokenProvider();
|
|
352
|
+
if (!token) {
|
|
353
|
+
// Not signed in — skip all retries this sweep.
|
|
354
|
+
tokenMissing = true;
|
|
355
|
+
skipped += items.length - items.indexOf(item);
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
// Record the attempt.
|
|
359
|
+
item.attempts++;
|
|
360
|
+
item.lastAttemptAt = new Date().toISOString();
|
|
361
|
+
const result = await attemptRetry(item, token);
|
|
362
|
+
if (result.outcome === 'success') {
|
|
363
|
+
await removeSuccessfulItem(item);
|
|
364
|
+
delivered++;
|
|
365
|
+
}
|
|
366
|
+
else if (result.outcome === 'permanent') {
|
|
367
|
+
item.lastError = result.error;
|
|
368
|
+
await markItemDead(item);
|
|
369
|
+
exhausted++;
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
// Transient — update attempt count and error on disk.
|
|
373
|
+
item.lastError = result.error;
|
|
374
|
+
if (item.attempts >= MAX_RETRY_ATTEMPTS) {
|
|
375
|
+
// Exhausted all attempts → mark dead.
|
|
376
|
+
await markItemDead(item);
|
|
377
|
+
exhausted++;
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
await persistUpdatedItem(item);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return { delivered, exhausted, skipped, tokenMissing };
|
|
385
|
+
}
|
|
386
|
+
// ── Background worker (process-lifetime) ──────────────────────────────────────
|
|
387
|
+
let workerTimer = null;
|
|
388
|
+
/**
|
|
389
|
+
* Start the background retry worker.
|
|
390
|
+
*
|
|
391
|
+
* Runs runRetrySweep() every WORKER_INTERVAL_MS milliseconds.
|
|
392
|
+
* Safe to call multiple times — subsequent calls are no-ops if already running.
|
|
393
|
+
*
|
|
394
|
+
* STORY-013 AC2: The worker drains the queue with exponential backoff.
|
|
395
|
+
* STORY-013 AC4: Items survive restarts; the worker picks them up on next sweep.
|
|
396
|
+
*
|
|
397
|
+
* @param clientSlug Bound client slug.
|
|
398
|
+
* @param tokenProvider Async function that returns a fresh access token or null.
|
|
399
|
+
*/
|
|
400
|
+
export function startRetryWorker(clientSlug, tokenProvider) {
|
|
401
|
+
if (workerTimer !== null)
|
|
402
|
+
return; // already running
|
|
403
|
+
workerTimer = setInterval(() => {
|
|
404
|
+
runRetrySweep(clientSlug, tokenProvider).catch((err) => {
|
|
405
|
+
// Worker errors are logged to stderr — never crash the MCP server.
|
|
406
|
+
process.stderr.write(`[sound-connect] retry-worker sweep error: ${err.message}\n`);
|
|
407
|
+
});
|
|
408
|
+
}, WORKER_INTERVAL_MS);
|
|
409
|
+
// Do not keep the Node process alive solely for the worker.
|
|
410
|
+
if (workerTimer.unref)
|
|
411
|
+
workerTimer.unref();
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Stop the background retry worker.
|
|
415
|
+
* Called during tests or graceful shutdown.
|
|
416
|
+
*/
|
|
417
|
+
export function stopRetryWorker() {
|
|
418
|
+
if (workerTimer !== null) {
|
|
419
|
+
clearInterval(workerTimer);
|
|
420
|
+
workerTimer = null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// ── Utility: clear all items (for testing only) ────────────────────────────────
|
|
424
|
+
/**
|
|
425
|
+
* Remove all pending and dead items from the queue directory.
|
|
426
|
+
* Intended for test teardown only — do not call in production paths.
|
|
427
|
+
*
|
|
428
|
+
* @param clientSlug Bound client slug.
|
|
429
|
+
*/
|
|
430
|
+
export async function clearQueue(clientSlug) {
|
|
431
|
+
const dir = queueDir(clientSlug);
|
|
432
|
+
await rm(dir, { recursive: true, force: true });
|
|
433
|
+
}
|
|
434
|
+
// ── Dead letter helpers ────────────────────────────────────────────────────────
|
|
435
|
+
/**
|
|
436
|
+
* Clear all dead items (permanent failures) from the queue.
|
|
437
|
+
* Useful after the user re-authenticates and wants to purge the dead letter list.
|
|
438
|
+
*
|
|
439
|
+
* @param clientSlug Bound client slug.
|
|
440
|
+
*/
|
|
441
|
+
export async function clearDeadItems(clientSlug) {
|
|
442
|
+
const dir = queueDir(clientSlug);
|
|
443
|
+
let entries;
|
|
444
|
+
try {
|
|
445
|
+
entries = await readdir(dir);
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
await Promise.all(entries
|
|
451
|
+
.filter(name => name.endsWith('.dead'))
|
|
452
|
+
.map(name => rm(join(dir, name), { force: true })));
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Get the base filename of a queue item (without extension).
|
|
456
|
+
* Exported for use in tests.
|
|
457
|
+
*/
|
|
458
|
+
export function itemBasename(item) {
|
|
459
|
+
return basename(pendingFilename(item), '.json');
|
|
460
|
+
}
|
|
461
|
+
//# sourceMappingURL=retry-queue.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry-queue.js","sourceRoot":"","sources":["../src/retry-queue.ts"],"names":[],"mappings":"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"}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registration for the Sound Connect bridge
|
|
3
|
+
* (STORY-006, STORY-009, STORY-010, STORY-011, STORY-012, STORY-013).
|
|
4
|
+
*
|
|
5
|
+
* STORY-010 / ADR-011: When the peer has not authenticated (no valid token at MCP
|
|
6
|
+
* startup), every tool call returns a clear MCP error directing the user to run
|
|
7
|
+
* `npx @soundbi/sound-connect login` in a terminal. The server never blocks on an
|
|
8
|
+
* interactive device-code prompt — that would cause a silent hang because Claude
|
|
9
|
+
* spawns the stdio server with stdout used for MCP framing (not displayed to the user).
|
|
10
|
+
*
|
|
11
|
+
* STORY-009 / ADR-005: One-client-per-instance isolation guard.
|
|
12
|
+
* - The bound clientSlug is fixed at process start. There is NO tool or command
|
|
13
|
+
* to switch clients in-session — any attempt to target a different slug is blocked.
|
|
14
|
+
* - assertResponseSlug() validates every backend response matches the bound slug so
|
|
15
|
+
* a misdirected backend response can never leak into this session.
|
|
16
|
+
*
|
|
17
|
+
* STORY-011 / ADR-006: ingest_file tool reads a local markdown file or transcript,
|
|
18
|
+
* normalizes, computes sha256, attaches provenance, and POSTs to /ingest/:slug.
|
|
19
|
+
*
|
|
20
|
+
* STORY-012 / ADR-006: ingest_folder tool bulk-ingests a directory of .md and
|
|
21
|
+
* transcript (.txt/.vtt/.srt) files, returning a per-file result table.
|
|
22
|
+
*
|
|
23
|
+
* STORY-013 / ADR-011: ingest_file and ingest_folder use queueOnFailure=true so
|
|
24
|
+
* transient backend failures (network / 5xx) queue chunks locally for retry.
|
|
25
|
+
* ingest_status tool reports pending/dead queue depth.
|
|
26
|
+
* The retry worker is started when registerBridgeTools() is called with an
|
|
27
|
+
* authenticated session.
|
|
28
|
+
*/
|
|
29
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
30
|
+
import type { BridgeConfig } from './config.js';
|
|
31
|
+
/**
|
|
32
|
+
* Asserts that a backend response belongs to the bound client slug.
|
|
33
|
+
*
|
|
34
|
+
* The backend may return a `client_slug` or `slug` field in its JSON response.
|
|
35
|
+
* If the value is present and does NOT match the bound slug, this function throws
|
|
36
|
+
* so the tool handler can reject the response as a cross-client leak.
|
|
37
|
+
*
|
|
38
|
+
* If the response carries no slug field (e.g. a list result or an error body),
|
|
39
|
+
* the check is skipped — the absence of a conflicting slug is not a violation.
|
|
40
|
+
*
|
|
41
|
+
* @param responseSlug The slug found in the backend response (may be undefined).
|
|
42
|
+
* @param boundSlug The slug this bridge instance is bound to.
|
|
43
|
+
* @throws Error if responseSlug is present and does not match boundSlug.
|
|
44
|
+
*/
|
|
45
|
+
export declare function assertResponseSlug(responseSlug: string | undefined, boundSlug: string): void;
|
|
46
|
+
/**
|
|
47
|
+
* Parses a JSON response body from the backend and asserts the slug matches.
|
|
48
|
+
* Returns the parsed body so callers do not have to parse twice.
|
|
49
|
+
*
|
|
50
|
+
* @param text Raw response text from the backend.
|
|
51
|
+
* @param boundSlug The slug this bridge instance is bound to.
|
|
52
|
+
* @returns Parsed JSON object (or null if parsing fails — slug check is skipped).
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseAndAssertResponseSlug(text: string, boundSlug: string): unknown;
|
|
55
|
+
/**
|
|
56
|
+
* Register bridge tools on the MCP server.
|
|
57
|
+
*
|
|
58
|
+
* All tools are visible in tools/list immediately (required by Claude Desktop/Code
|
|
59
|
+
* to show the server as functional). When `isAuthenticated` is false, every tool
|
|
60
|
+
* call returns the NOT_SIGNED_IN_TEXT error — never blocks or hangs (STORY-010).
|
|
61
|
+
* When `isAuthenticated` is true, tool calls return NOT_YET_IMPLEMENTED_TEXT until
|
|
62
|
+
* STORY-008 wires real proxy behaviour.
|
|
63
|
+
*
|
|
64
|
+
* STORY-009 / ADR-005: There is deliberately NO "select_client", "switch_client",
|
|
65
|
+
* or equivalent tool registered here. The bound slug is fixed at process start and
|
|
66
|
+
* cannot be changed in-session. Any outbound call that receives a response for a
|
|
67
|
+
* different slug must call assertResponseSlug() / parseAndAssertResponseSlug() to
|
|
68
|
+
* reject the response before returning it to Claude.
|
|
69
|
+
*
|
|
70
|
+
* @param server The McpServer instance.
|
|
71
|
+
* @param config Bridge config (backendUrl, clientSlug) — fixed at startup.
|
|
72
|
+
* @param isAuthenticated Whether a valid cached token was found at startup (STORY-010).
|
|
73
|
+
*/
|
|
74
|
+
export declare function registerBridgeTools(server: McpServer, config: BridgeConfig, isAuthenticated: boolean): void;
|
|
75
|
+
//# sourceMappingURL=tools.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAOhD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,SAAS,EAAE,MAAM,GAChB,IAAI,CAQN;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,GAChB,OAAO,CAqBT;AA+BD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,SAAS,EACjB,MAAM,EAAE,YAAY,EACpB,eAAe,EAAE,OAAO,GACvB,IAAI,CA8SN"}
|