@purveyors/cli 0.14.0 → 0.15.1

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.
@@ -2,16 +2,25 @@
2
2
  * Watch module for `purvey roast watch`.
3
3
  * CLI-only — not exported via package.json subpaths.
4
4
  *
5
- * Monitors a directory for new .alog files, imports them automatically,
5
+ * Monitors a directory for new .alog files, queues or imports them,
6
6
  * and shows a verification table when the session ends (Ctrl+C).
7
7
  */
8
+ import { watch } from 'fs';
9
+ import { readFile, access } from 'fs/promises';
8
10
  import type { SupabaseClient } from '@supabase/supabase-js';
11
+ import { importRoastFromFile } from '../roast.js';
9
12
  import type { MilestoneData, ProcessedRoastData } from '../artisan/types.js';
10
13
  export interface WatchSession {
11
14
  directory: string;
12
15
  coffeeId: number;
13
16
  coffeeName: string;
14
17
  batchPrefix: string;
18
+ promptEach?: boolean;
19
+ autoMatch?: boolean;
20
+ commitMode?: 'batch' | 'individual';
21
+ ozIn?: number;
22
+ roastNotes?: string;
23
+ roastTargets?: string;
15
24
  startedAt: string;
16
25
  imports: ImportRecord[];
17
26
  }
@@ -19,11 +28,13 @@ export interface ImportRecord {
19
28
  fileName: string;
20
29
  roastId: number | null;
21
30
  batchName: string;
22
- status: 'success' | 'failed' | 'needs-review';
31
+ status: 'pending' | 'success' | 'failed' | 'needs-review';
23
32
  error?: string;
24
33
  milestones?: MilestoneData;
25
34
  phases?: ProcessedRoastData['phases'];
26
35
  importedAt: string;
36
+ selectedCoffeeId?: number;
37
+ selectedCoffeeName?: string;
27
38
  aiMatch?: {
28
39
  coffeeName: string;
29
40
  confidence: number;
@@ -34,10 +45,33 @@ interface StartWatchOpts {
34
45
  coffeeId: number;
35
46
  coffeeName: string;
36
47
  batchPrefix: string;
48
+ commitMode?: 'batch' | 'individual';
49
+ startedAt?: string;
50
+ resumeImports?: ImportRecord[];
37
51
  promptEach?: boolean;
38
52
  startSequence?: number;
39
53
  autoMatch?: boolean;
54
+ ozIn?: number;
55
+ roastNotes?: string;
56
+ roastTargets?: string;
57
+ }
58
+ export interface StartWatchRuntime {
59
+ accessImpl?: typeof access;
60
+ readFileImpl?: typeof readFile;
61
+ readdirImpl?: (typeof import('fs/promises'))['readdir'];
62
+ saveWatchSessionImpl?: typeof saveWatchSession;
63
+ importRoastFromFileImpl?: typeof importRoastFromFile;
64
+ watchImpl?: typeof watch;
65
+ addSignalListener?: (signal: 'SIGINT' | 'SIGTERM', listener: () => void) => void;
66
+ removeSignalListener?: (signal: 'SIGINT' | 'SIGTERM', listener: () => void) => void;
67
+ debounceMs?: number;
68
+ }
69
+ interface ManualImportRecoveryOptions {
70
+ ozIn?: number;
71
+ roastNotes?: string;
72
+ roastTargets?: string;
40
73
  }
74
+ export declare function buildManualImportRecoveryCommand(options?: ManualImportRecoveryOptions): string;
41
75
  /** Persist session state to disk after each import. */
42
76
  export declare function saveWatchSession(session: WatchSession): Promise<void>;
43
77
  /** Load a previously saved watch session. Returns null if none exists. */
@@ -61,6 +95,6 @@ export declare function printVerificationTable(session: WatchSession, autoMatch?
61
95
  * @param opts Watch options
62
96
  * @returns The final WatchSession (all imports recorded)
63
97
  */
64
- export declare function startWatch(supabase: SupabaseClient, userId: string, directory: string, opts: StartWatchOpts): Promise<WatchSession>;
98
+ export declare function startWatch(supabase: SupabaseClient, userId: string, directory: string, opts: StartWatchOpts, runtime?: StartWatchRuntime): Promise<WatchSession>;
65
99
  export {};
66
100
  //# sourceMappingURL=watch.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"watch.d.ts","sourceRoot":"","sources":["../../../src/lib/interactive/watch.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAMH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAE5D,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAK7E,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,YAAY,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,cAAc,CAAC;IAC9C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,aAAa,CAAC;IAC3B,MAAM,CAAC,EAAE,kBAAkB,CAAC,QAAQ,CAAC,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC;IAEnB,OAAO,CAAC,EAAE;QACR,UAAU,EAAE,MAAM,CAAC;QACnB,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;CACH;AAED,UAAU,cAAc;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAMD,uDAAuD;AACvD,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAG3E;AAED,0EAA0E;AAC1E,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAQrE;AAID;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAE1E;AAID,yEAAyE;AACzE,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEpD;AAID,8DAA8D;AAC9D,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,CAAC,EAAE,OAAO,GAAG,IAAI,CASvF;AA8JD;;;;;;;;;GASG;AACH,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,cAAc,EACxB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,cAAc,GACnB,OAAO,CAAC,YAAY,CAAC,CAiPvB"}
1
+ {"version":3,"file":"watch.d.ts","sourceRoot":"","sources":["../../../src/lib/interactive/watch.ts"],"names":[],"mappings":"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"}
@@ -2,7 +2,7 @@
2
2
  * Watch module for `purvey roast watch`.
3
3
  * CLI-only — not exported via package.json subpaths.
4
4
  *
5
- * Monitors a directory for new .alog files, imports them automatically,
5
+ * Monitors a directory for new .alog files, queues or imports them,
6
6
  * and shows a verification table when the session ends (Ctrl+C).
7
7
  */
8
8
  import { watch } from 'fs';
@@ -11,6 +11,22 @@ import { join, extname } from 'path';
11
11
  import { constants } from 'fs';
12
12
  import { importRoastFromFile } from '../roast.js';
13
13
  import { CONFIG_DIR } from '../config.js';
14
+ function quoteCliArg(value) {
15
+ return JSON.stringify(value);
16
+ }
17
+ export function buildManualImportRecoveryCommand(options = {}) {
18
+ let command = 'purvey roast import <file> --coffee-id <id>';
19
+ if (options.ozIn !== undefined) {
20
+ command += ` --oz-in ${options.ozIn}`;
21
+ }
22
+ if (options.roastNotes !== undefined && options.roastNotes.trim() !== '') {
23
+ command += ` --roast-notes ${quoteCliArg(options.roastNotes.trim())}`;
24
+ }
25
+ if (options.roastTargets !== undefined && options.roastTargets.trim() !== '') {
26
+ command += ` --roast-targets ${quoteCliArg(options.roastTargets.trim())}`;
27
+ }
28
+ return command;
29
+ }
14
30
  // ─── Session persistence ──────────────────────────────────────────────────────
15
31
  const SESSION_FILE = join(CONFIG_DIR, 'watch-session.json');
16
32
  /** Persist session state to disk after each import. */
@@ -97,15 +113,24 @@ function printVerificationTableStandard(imports) {
97
113
  const batch = rec.batchName.length > COL_BATCH
98
114
  ? rec.batchName.slice(0, COL_BATCH - 1) + '…'
99
115
  : rec.batchName;
100
- const status = rec.status === 'success' ? '✓' : `✗ ${rec.error?.slice(0, 5) ?? 'Error'}`;
116
+ const status = rec.status === 'success'
117
+ ? '✓'
118
+ : rec.status === 'pending'
119
+ ? '… queued'
120
+ : `✗ ${rec.error?.slice(0, 5) ?? 'Error'}`;
101
121
  lines.push(row(file, id, batch, status));
102
122
  }
103
123
  }
104
124
  lines.push(hline('└', '┴', '┘', '─'));
105
125
  const succeeded = imports.filter((r) => r.status === 'success').length;
106
126
  const failed = imports.filter((r) => r.status === 'failed').length;
127
+ const pending = imports.filter((r) => r.status === 'pending').length;
107
128
  lines.push('');
108
- lines.push(`Session: ${imports.length} file${imports.length !== 1 ? 's' : ''} processed, ${succeeded} succeeded, ${failed} failed`);
129
+ let summary = `Session: ${imports.length} file${imports.length !== 1 ? 's' : ''} processed, ${succeeded} succeeded, ${failed} failed`;
130
+ if (pending > 0) {
131
+ summary += `, ${pending} queued`;
132
+ }
133
+ lines.push(summary);
109
134
  lines.push('');
110
135
  process.stderr.write(lines.join('\n') + '\n');
111
136
  }
@@ -160,9 +185,11 @@ function printVerificationTableAutoMatch(imports) {
160
185
  const conf = confVal.padEnd(COL_CONF);
161
186
  const status = rec.status === 'success'
162
187
  ? '✓'
163
- : rec.status === 'needs-review'
164
- ? ''
165
- : `✗ ${rec.error?.slice(0, 4) ?? 'Err'}`;
188
+ : rec.status === 'pending'
189
+ ? ''
190
+ : rec.status === 'needs-review'
191
+ ? '⚠'
192
+ : `✗ ${rec.error?.slice(0, 4) ?? 'Err'}`;
166
193
  lines.push(row(file, id, bean, conf, status));
167
194
  }
168
195
  }
@@ -170,11 +197,15 @@ function printVerificationTableAutoMatch(imports) {
170
197
  const succeeded = imports.filter((r) => r.status === 'success').length;
171
198
  const failed = imports.filter((r) => r.status === 'failed').length;
172
199
  const needsReview = imports.filter((r) => r.status === 'needs-review').length;
200
+ const pending = imports.filter((r) => r.status === 'pending').length;
173
201
  lines.push('');
174
202
  let summary = `Session: ${imports.length} file${imports.length !== 1 ? 's' : ''} processed, ${succeeded} succeeded, ${failed} failed`;
175
203
  if (needsReview > 0) {
176
204
  summary += `, ${needsReview} need${needsReview !== 1 ? '' : 's'} review`;
177
205
  }
206
+ if (pending > 0) {
207
+ summary += `, ${pending} queued`;
208
+ }
178
209
  lines.push(summary);
179
210
  lines.push('');
180
211
  process.stderr.write(lines.join('\n') + '\n');
@@ -190,19 +221,27 @@ function printVerificationTableAutoMatch(imports) {
190
221
  * @param opts Watch options
191
222
  * @returns The final WatchSession (all imports recorded)
192
223
  */
193
- export async function startWatch(supabase, userId, directory, opts) {
224
+ export async function startWatch(supabase, userId, directory, opts, runtime = {}) {
225
+ const accessFile = runtime.accessImpl ?? access;
226
+ const readFileText = runtime.readFileImpl ?? readFile;
227
+ const saveSession = runtime.saveWatchSessionImpl ?? saveWatchSession;
228
+ const importRoast = runtime.importRoastFromFileImpl ?? importRoastFromFile;
229
+ const watchDirectory = runtime.watchImpl ?? watch;
230
+ const addSignalListener = runtime.addSignalListener ?? process.on.bind(process);
231
+ const removeSignalListener = runtime.removeSignalListener ?? process.removeListener.bind(process);
232
+ const debounceMs = runtime.debounceMs ?? 2000;
194
233
  // 1. Validate directory exists
195
234
  try {
196
- await access(directory, constants.R_OK);
235
+ await accessFile(directory, constants.R_OK);
197
236
  }
198
237
  catch {
199
238
  throw new Error(`Directory not found or not readable: "${directory}"`);
200
239
  }
201
240
  // 2. Snapshot existing .alog files so we only react to NEW ones
202
- const { readdir } = await import('fs/promises');
241
+ const readdirEntries = runtime.readdirImpl ?? (await import('fs/promises')).readdir;
203
242
  const existingFiles = new Set();
204
243
  try {
205
- const entries = await readdir(directory);
244
+ const entries = await readdirEntries(directory);
206
245
  for (const entry of entries) {
207
246
  if (isAlogFile(entry)) {
208
247
  existingFiles.add(entry);
@@ -212,26 +251,100 @@ export async function startWatch(supabase, userId, directory, opts) {
212
251
  catch {
213
252
  // Non-fatal — if readdir fails we just won't filter pre-existing files
214
253
  }
254
+ const commitMode = opts.commitMode ?? 'batch';
215
255
  // 3. Create session state
216
256
  const session = {
217
257
  directory,
218
258
  coffeeId: opts.coffeeId,
219
259
  coffeeName: opts.coffeeName,
220
260
  batchPrefix: opts.batchPrefix,
221
- startedAt: new Date().toISOString(),
222
- imports: [],
261
+ promptEach: opts.promptEach ?? false,
262
+ autoMatch: opts.autoMatch ?? false,
263
+ commitMode,
264
+ ...(opts.ozIn !== undefined ? { ozIn: opts.ozIn } : {}),
265
+ ...(opts.roastNotes !== undefined ? { roastNotes: opts.roastNotes } : {}),
266
+ ...(opts.roastTargets !== undefined ? { roastTargets: opts.roastTargets } : {}),
267
+ startedAt: opts.startedAt ?? new Date().toISOString(),
268
+ imports: opts.resumeImports ? [...opts.resumeImports] : [],
223
269
  };
224
270
  const startSequence = opts.startSequence ?? 0;
225
271
  // 4. Debounce map: filename → timeout handle
226
272
  const debounceTimers = new Map();
227
273
  // Track in-progress files to avoid double-processing
228
274
  const processing = new Set();
275
+ const activeTasks = new Set();
276
+ const queuedImports = new Map();
277
+ let shuttingDown = false;
278
+ for (const record of session.imports) {
279
+ if (record.status === 'pending' && record.selectedCoffeeId && record.selectedCoffeeName) {
280
+ queuedImports.set(record.fileName, {
281
+ record,
282
+ coffeeId: record.selectedCoffeeId,
283
+ coffeeName: record.selectedCoffeeName,
284
+ });
285
+ }
286
+ }
287
+ async function commitQueuedImport(queued) {
288
+ const { record, coffeeId, coffeeName } = queued;
289
+ let fileContent = queued.fileContent;
290
+ if (!fileContent) {
291
+ const filePath = join(directory, record.fileName);
292
+ try {
293
+ await accessFile(filePath, constants.R_OK);
294
+ fileContent = await readFileText(filePath, 'utf-8');
295
+ }
296
+ catch (err) {
297
+ const errMsg = err instanceof Error ? err.message : 'Unreadable';
298
+ record.status = 'failed';
299
+ record.error = errMsg;
300
+ record.importedAt = new Date().toISOString();
301
+ queuedImports.delete(record.fileName);
302
+ await saveSession(session);
303
+ process.stderr.write(`✗ Failed to read ${record.fileName}: ${errMsg}\n`);
304
+ return;
305
+ }
306
+ }
307
+ try {
308
+ const result = await importRoast(supabase, userId, {
309
+ fileContent,
310
+ fileName: record.fileName,
311
+ coffeeId,
312
+ batchName: record.batchName,
313
+ ozIn: opts.ozIn,
314
+ roastNotes: opts.roastNotes,
315
+ roastTargets: opts.roastTargets,
316
+ });
317
+ const milestoneCount = Object.values(result.milestones).filter((v) => v !== undefined && v > 0).length;
318
+ const tempCount = result.message.match(/(\d+) data points/)?.[1] ?? '?';
319
+ record.roastId = result.roast_id;
320
+ record.status = 'success';
321
+ record.error = undefined;
322
+ record.milestones = result.milestones;
323
+ record.phases = result.phases;
324
+ record.importedAt = new Date().toISOString();
325
+ record.selectedCoffeeId = coffeeId;
326
+ record.selectedCoffeeName = coffeeName;
327
+ queuedImports.delete(record.fileName);
328
+ await saveSession(session);
329
+ process.stderr.write(`✓ Imported: ${record.fileName} → Roast #${result.roast_id} (${tempCount} temps, ${milestoneCount} milestones)` +
330
+ (coffeeName !== opts.coffeeName ? ` [${coffeeName}]` : '') +
331
+ '\n');
332
+ }
333
+ catch (err) {
334
+ const errMsg = err instanceof Error ? err.message : String(err);
335
+ record.status = 'failed';
336
+ record.error = errMsg;
337
+ record.importedAt = new Date().toISOString();
338
+ queuedImports.delete(record.fileName);
339
+ await saveSession(session);
340
+ process.stderr.write(`✗ Failed to import ${record.fileName}: ${errMsg}\n`);
341
+ }
342
+ }
229
343
  // processFile: read, import, record result
230
344
  async function processFile(filename) {
231
- if (processing.has(filename))
345
+ if (processing.has(filename) || shuttingDown)
232
346
  return;
233
347
  processing.add(filename);
234
- const filePath = join(directory, filename);
235
348
  const sequence = startSequence + session.imports.length + 1;
236
349
  const batchName = generateBatchName(opts.batchPrefix, sequence);
237
350
  // If promptEach: let user override the bean selection
@@ -245,10 +358,11 @@ export async function startWatch(supabase, userId, directory, opts) {
245
358
  effectiveCoffeeId = bean.id;
246
359
  effectiveCoffeeName = bean.name;
247
360
  }
361
+ const filePath = join(directory, filename);
248
362
  let fileContent;
249
363
  try {
250
- await access(filePath, constants.R_OK);
251
- fileContent = await readFile(filePath, 'utf-8');
364
+ await accessFile(filePath, constants.R_OK);
365
+ fileContent = await readFileText(filePath, 'utf-8');
252
366
  }
253
367
  catch (err) {
254
368
  const record = {
@@ -260,7 +374,7 @@ export async function startWatch(supabase, userId, directory, opts) {
260
374
  importedAt: new Date().toISOString(),
261
375
  };
262
376
  session.imports.push(record);
263
- await saveWatchSession(session);
377
+ await saveSession(session);
264
378
  process.stderr.write(`✗ Failed to read ${filename}: ${record.error}\n`);
265
379
  processing.delete(filename);
266
380
  return;
@@ -281,7 +395,7 @@ export async function startWatch(supabase, userId, directory, opts) {
281
395
  ...(aiResult.aiMatch !== undefined ? { aiMatch: aiResult.aiMatch } : {}),
282
396
  };
283
397
  session.imports.push(record);
284
- await saveWatchSession(session);
398
+ await saveSession(session);
285
399
  const confStr = aiResult.aiMatch !== undefined ? ` (confidence: ${aiResult.aiMatch.confidence}%)` : '';
286
400
  process.stderr.write(`⚠ Needs review: ${filename}${confStr} — ${aiResult.reason}\n`);
287
401
  processing.delete(filename);
@@ -292,46 +406,34 @@ export async function startWatch(supabase, userId, directory, opts) {
292
406
  process.stderr.write(`🤖 AI matched: ${filename} → ${effectiveCoffeeName} (${aiResult.aiMatch.confidence}% confidence)\n` +
293
407
  ` Reasoning: ${aiResult.aiMatch.reasoning}\n`);
294
408
  }
409
+ const aiMatchField = opts.autoMatch && !opts.promptEach && aiResult?.aiMatch ? aiResult.aiMatch : undefined;
410
+ const record = {
411
+ fileName: filename,
412
+ roastId: null,
413
+ batchName,
414
+ status: 'pending',
415
+ importedAt: new Date().toISOString(),
416
+ selectedCoffeeId: effectiveCoffeeId,
417
+ selectedCoffeeName: effectiveCoffeeName,
418
+ ...(aiMatchField !== undefined ? { aiMatch: aiMatchField } : {}),
419
+ };
420
+ session.imports.push(record);
421
+ const queued = {
422
+ record,
423
+ coffeeId: effectiveCoffeeId,
424
+ coffeeName: effectiveCoffeeName,
425
+ fileContent,
426
+ };
295
427
  try {
296
- const result = await importRoastFromFile(supabase, userId, {
297
- fileContent,
298
- fileName: filename,
299
- coffeeId: effectiveCoffeeId,
300
- batchName,
301
- });
302
- const milestoneCount = Object.values(result.milestones).filter((v) => v !== undefined && v > 0).length;
303
- // Carry aiMatch from the local aiResult (threaded as a local variable, not module state)
304
- const aiMatchField = opts.autoMatch && !opts.promptEach && aiResult?.aiMatch ? aiResult.aiMatch : undefined;
305
- const record = {
306
- fileName: filename,
307
- roastId: result.roast_id,
308
- batchName,
309
- status: 'success',
310
- milestones: result.milestones,
311
- phases: result.phases,
312
- importedAt: new Date().toISOString(),
313
- ...(aiMatchField !== undefined ? { aiMatch: aiMatchField } : {}),
314
- };
315
- session.imports.push(record);
316
- await saveWatchSession(session);
317
- const tempCount = result.message.match(/(\d+) data points/)?.[1] ?? '?';
318
- process.stderr.write(`✓ Imported: ${filename} → Roast #${result.roast_id} (${tempCount} temps, ${milestoneCount} milestones)` +
319
- (effectiveCoffeeName !== opts.coffeeName ? ` [${effectiveCoffeeName}]` : '') +
320
- '\n');
321
- }
322
- catch (err) {
323
- const errMsg = err instanceof Error ? err.message : String(err);
324
- const record = {
325
- fileName: filename,
326
- roastId: null,
327
- batchName,
328
- status: 'failed',
329
- error: errMsg,
330
- importedAt: new Date().toISOString(),
331
- };
332
- session.imports.push(record);
333
- await saveWatchSession(session);
334
- process.stderr.write(`✗ Failed to import ${filename}: ${errMsg}\n`);
428
+ if (commitMode === 'batch') {
429
+ queuedImports.set(filename, queued);
430
+ await saveSession(session);
431
+ process.stderr.write(`… Queued: ${filename} → ${batchName}` +
432
+ (effectiveCoffeeName !== opts.coffeeName ? ` [${effectiveCoffeeName}]` : '') +
433
+ '\n');
434
+ return;
435
+ }
436
+ await commitQueuedImport(queued);
335
437
  }
336
438
  finally {
337
439
  processing.delete(filename);
@@ -339,7 +441,7 @@ export async function startWatch(supabase, userId, directory, opts) {
339
441
  }
340
442
  // debounced handler
341
443
  function onFileEvent(filename) {
342
- if (!isAlogFile(filename))
444
+ if (!isAlogFile(filename) || shuttingDown)
343
445
  return;
344
446
  // Skip files that were present when we started
345
447
  if (existingFiles.has(filename))
@@ -349,15 +451,20 @@ export async function startWatch(supabase, userId, directory, opts) {
349
451
  }
350
452
  debounceTimers.set(filename, setTimeout(() => {
351
453
  debounceTimers.delete(filename);
352
- processFile(filename).catch((err) => {
454
+ const task = processFile(filename)
455
+ .catch((err) => {
353
456
  process.stderr.write(`✗ Unexpected error processing ${filename}: ${err instanceof Error ? err.message : String(err)}\n`);
457
+ })
458
+ .finally(() => {
459
+ activeTasks.delete(task);
354
460
  });
355
- }, 2000));
461
+ activeTasks.add(task);
462
+ }, debounceMs));
356
463
  }
357
464
  // 5. Start fs.watch
358
465
  let watcher;
359
466
  try {
360
- watcher = watch(directory, { persistent: true }, (_eventType, filename) => {
467
+ watcher = watchDirectory(directory, { persistent: true }, (_eventType, filename) => {
361
468
  if (filename)
362
469
  onFileEvent(filename);
363
470
  });
@@ -370,30 +477,79 @@ export async function startWatch(supabase, userId, directory, opts) {
370
477
  : opts.promptEach
371
478
  ? 'prompt-each mode'
372
479
  : `coffee: ${opts.coffeeName}`;
373
- process.stderr.write(`👁 Watching ${directory} for new .alog files (${modeLabel}, prefix: "${opts.batchPrefix}")...\n`);
480
+ process.stderr.write(`👁 Watching ${directory} for new .alog files (${modeLabel}, ${commitMode} commit mode, prefix: "${opts.batchPrefix}")...\n`);
374
481
  process.stderr.write(` Press Ctrl+C to stop and view summary.\n\n`);
375
482
  // 6. Block until SIGINT, then cleanup and return
376
- await new Promise((resolve) => {
377
- process.once('SIGINT', () => {
378
- // Cancel all pending debounce timers
379
- for (const timer of debounceTimers.values()) {
380
- clearTimeout(timer);
483
+ await new Promise((resolve, reject) => {
484
+ let shutdownPromise = null;
485
+ let repeatedSignalNoticePrinted = false;
486
+ const cleanupSignalHandlers = () => {
487
+ removeSignalListener('SIGINT', onSigint);
488
+ removeSignalListener('SIGTERM', onSigterm);
489
+ };
490
+ const printRepeatedSignalNotice = (signal) => {
491
+ if (repeatedSignalNoticePrinted)
492
+ return;
493
+ repeatedSignalNoticePrinted = true;
494
+ process.stderr.write(`⏳ Shutdown already in progress (${signal}). Waiting for active imports and queued roasts to finish...\n`);
495
+ };
496
+ const shutdown = (signal) => {
497
+ if (shutdownPromise) {
498
+ printRepeatedSignalNotice(signal);
499
+ return shutdownPromise;
381
500
  }
382
- debounceTimers.clear();
383
- watcher.close();
384
- process.stderr.write('\n');
385
- printVerificationTable(session, opts.autoMatch);
386
- // Prompt about unmatched files if in auto-match mode
387
- const needsReview = session.imports.filter((r) => r.status === 'needs-review');
388
- if (opts.autoMatch && needsReview.length > 0) {
389
- process.stderr.write(`⚠ ${needsReview.length} file${needsReview.length !== 1 ? 's need' : ' needs'} manual bean assignment:\n`);
390
- for (const rec of needsReview) {
391
- process.stderr.write(` - ${rec.fileName}\n`);
501
+ shuttingDown = true;
502
+ shutdownPromise = (async () => {
503
+ // Cancel all pending debounce timers
504
+ for (const timer of debounceTimers.values()) {
505
+ clearTimeout(timer);
392
506
  }
393
- process.stderr.write(` Use \`purvey roast import <file> --coffee-id <id>\` to import them manually.\n\n`);
394
- }
395
- resolve();
396
- });
507
+ debounceTimers.clear();
508
+ watcher.close();
509
+ try {
510
+ await Promise.allSettled([...activeTasks]);
511
+ process.stderr.write('\n');
512
+ printVerificationTable(session, opts.autoMatch);
513
+ if (commitMode === 'batch' && queuedImports.size > 0) {
514
+ process.stderr.write(`🗂 Committing ${queuedImports.size} queued roast${queuedImports.size !== 1 ? 's' : ''}...\n`);
515
+ for (const queued of [...queuedImports.values()]) {
516
+ await commitQueuedImport(queued);
517
+ }
518
+ process.stderr.write('\n');
519
+ printVerificationTable(session, opts.autoMatch);
520
+ }
521
+ // Prompt about unmatched files if in auto-match mode
522
+ const needsReview = session.imports.filter((r) => r.status === 'needs-review');
523
+ if (opts.autoMatch && needsReview.length > 0) {
524
+ process.stderr.write(`⚠ ${needsReview.length} file${needsReview.length !== 1 ? 's need' : ' needs'} manual bean assignment:\n`);
525
+ for (const rec of needsReview) {
526
+ process.stderr.write(` - ${rec.fileName}\n`);
527
+ }
528
+ process.stderr.write(` Use \`${buildManualImportRecoveryCommand({
529
+ ozIn: opts.ozIn,
530
+ roastNotes: opts.roastNotes,
531
+ roastTargets: opts.roastTargets,
532
+ })}\` to import them manually.\n\n`);
533
+ }
534
+ resolve();
535
+ }
536
+ catch (error) {
537
+ reject(error);
538
+ }
539
+ finally {
540
+ cleanupSignalHandlers();
541
+ }
542
+ })();
543
+ return shutdownPromise;
544
+ };
545
+ const onSigint = () => {
546
+ void shutdown('SIGINT');
547
+ };
548
+ const onSigterm = () => {
549
+ void shutdown('SIGTERM');
550
+ };
551
+ addSignalListener('SIGINT', onSigint);
552
+ addSignalListener('SIGTERM', onSigterm);
397
553
  });
398
554
  return session;
399
555
  }