@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.
- package/README.md +366 -211
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +5 -3
- package/dist/commands/context.js.map +1 -1
- package/dist/commands/manifest.d.ts.map +1 -1
- package/dist/commands/manifest.js +5 -1
- package/dist/commands/manifest.js.map +1 -1
- package/dist/commands/roast.d.ts.map +1 -1
- package/dist/commands/roast.js +105 -2
- package/dist/commands/roast.js.map +1 -1
- package/dist/commands/sales.d.ts.map +1 -1
- package/dist/commands/sales.js +87 -40
- package/dist/commands/sales.js.map +1 -1
- package/dist/lib/interactive/watch.d.ts +37 -3
- package/dist/lib/interactive/watch.d.ts.map +1 -1
- package/dist/lib/interactive/watch.js +237 -81
- package/dist/lib/interactive/watch.js.map +1 -1
- package/dist/lib/manifest.d.ts +7 -0
- package/dist/lib/manifest.d.ts.map +1 -1
- package/dist/lib/manifest.js +71 -13
- package/dist/lib/manifest.js.map +1 -1
- package/dist/lib/roast.d.ts +2 -0
- package/dist/lib/roast.d.ts.map +1 -1
- package/dist/lib/roast.js +5 -2
- package/dist/lib/roast.js.map +1 -1
- package/dist/lib/sales.d.ts +15 -2
- package/dist/lib/sales.d.ts.map +1 -1
- package/dist/lib/sales.js +77 -15
- package/dist/lib/sales.js.map +1 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +10 -6
- package/dist/program.js.map +1 -1
- package/package.json +6 -1
|
@@ -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
|
|
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;
|
|
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
|
|
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'
|
|
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
|
-
|
|
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 === '
|
|
164
|
-
? '
|
|
165
|
-
:
|
|
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
|
|
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
|
|
241
|
+
const readdirEntries = runtime.readdirImpl ?? (await import('fs/promises')).readdir;
|
|
203
242
|
const existingFiles = new Set();
|
|
204
243
|
try {
|
|
205
|
-
const entries = await
|
|
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
|
-
|
|
222
|
-
|
|
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
|
|
251
|
-
fileContent = await
|
|
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
|
|
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
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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)
|
|
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
|
-
|
|
461
|
+
activeTasks.add(task);
|
|
462
|
+
}, debounceMs));
|
|
356
463
|
}
|
|
357
464
|
// 5. Start fs.watch
|
|
358
465
|
let watcher;
|
|
359
466
|
try {
|
|
360
|
-
watcher =
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
}
|