@qianxude/tem 0.2.0 → 0.4.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/package.json +6 -1
- package/src/cli/commands/list.ts +141 -0
- package/src/cli/commands/report.ts +453 -0
- package/src/cli/commands/watch.ts +561 -0
- package/src/cli/index.ts +135 -0
- package/src/cli/utils/format.ts +77 -0
- package/src/cli/utils/table.ts +116 -0
- package/src/core/tem.ts +29 -1
- package/src/core/worker.ts +78 -6
- package/src/database/index.ts +47 -7
- package/src/index.ts +1 -1
- package/src/interfaces/index.ts +60 -0
- package/src/services/batch-interruption.ts +192 -0
- package/src/services/batch.ts +32 -4
- package/src/services/index.ts +1 -0
- package/src/utils/auto-detect.ts +5 -2
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
import { Database } from '../../database/index.js';
|
|
2
|
+
import {
|
|
3
|
+
formatDuration,
|
|
4
|
+
formatTimestamp,
|
|
5
|
+
formatNumber,
|
|
6
|
+
formatPercent,
|
|
7
|
+
truncate,
|
|
8
|
+
} from '../utils/format.js';
|
|
9
|
+
import { renderTable, renderKeyValue } from '../utils/table.js';
|
|
10
|
+
|
|
11
|
+
interface BatchSummary {
|
|
12
|
+
id: string;
|
|
13
|
+
code: string;
|
|
14
|
+
type: string;
|
|
15
|
+
created_at: string;
|
|
16
|
+
completed_at: string | null;
|
|
17
|
+
total: number;
|
|
18
|
+
pending: number;
|
|
19
|
+
running: number;
|
|
20
|
+
completed: number;
|
|
21
|
+
failed: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TaskTiming {
|
|
25
|
+
avg_time: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface RecentError {
|
|
29
|
+
error: string;
|
|
30
|
+
failed_at: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface WatchOptions {
|
|
34
|
+
interval: number;
|
|
35
|
+
timeout: number;
|
|
36
|
+
noClear: boolean;
|
|
37
|
+
latest: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface WatchState {
|
|
41
|
+
startTime: number;
|
|
42
|
+
lastUpdateTime: number;
|
|
43
|
+
completedCount: number;
|
|
44
|
+
shouldExit: boolean;
|
|
45
|
+
exitCode: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function openDatabase(dbPath: string): Database {
|
|
49
|
+
return new Database({ path: dbPath, busyTimeout: 5000 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getBatchSummary(db: Database, batchId: string): BatchSummary | null {
|
|
53
|
+
const rows = db.query<BatchSummary>(
|
|
54
|
+
`SELECT
|
|
55
|
+
b.id,
|
|
56
|
+
b.code,
|
|
57
|
+
b.type,
|
|
58
|
+
b.created_at,
|
|
59
|
+
b.completed_at,
|
|
60
|
+
COUNT(t.id) as total,
|
|
61
|
+
SUM(CASE WHEN t.status = 'pending' THEN 1 ELSE 0 END) as pending,
|
|
62
|
+
SUM(CASE WHEN t.status = 'running' THEN 1 ELSE 0 END) as running,
|
|
63
|
+
SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed,
|
|
64
|
+
SUM(CASE WHEN t.status = 'failed' THEN 1 ELSE 0 END) as failed
|
|
65
|
+
FROM batch b
|
|
66
|
+
LEFT JOIN task t ON t.batch_id = b.id
|
|
67
|
+
WHERE b.id = ?
|
|
68
|
+
GROUP BY b.id`,
|
|
69
|
+
[batchId]
|
|
70
|
+
);
|
|
71
|
+
return rows[0] || null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getBatchByCode(db: Database, code: string): { id: string } | null {
|
|
75
|
+
const rows = db.query<{ id: string }>(
|
|
76
|
+
'SELECT id FROM batch WHERE code = ?',
|
|
77
|
+
[code]
|
|
78
|
+
);
|
|
79
|
+
return rows[0] || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getLatestBatch(db: Database): { id: string; code: string } | null {
|
|
83
|
+
const rows = db.query<{ id: string; code: string }>(
|
|
84
|
+
'SELECT id, code FROM batch ORDER BY created_at DESC LIMIT 1'
|
|
85
|
+
);
|
|
86
|
+
return rows[0] || null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getTaskTiming(db: Database, batchId: string): TaskTiming | null {
|
|
90
|
+
const rows = db.query<TaskTiming>(
|
|
91
|
+
`SELECT
|
|
92
|
+
AVG(
|
|
93
|
+
unixepoch(completed_at) - unixepoch(created_at)
|
|
94
|
+
) * 1000 as avg_time
|
|
95
|
+
FROM task
|
|
96
|
+
WHERE batch_id = ?
|
|
97
|
+
AND status = 'completed'
|
|
98
|
+
AND completed_at IS NOT NULL`,
|
|
99
|
+
[batchId]
|
|
100
|
+
);
|
|
101
|
+
return rows[0] || null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getRecentErrors(db: Database, batchId: string, limit: number): RecentError[] {
|
|
105
|
+
return db.query<RecentError>(
|
|
106
|
+
`SELECT
|
|
107
|
+
error,
|
|
108
|
+
failed_at
|
|
109
|
+
FROM task
|
|
110
|
+
WHERE batch_id = ?
|
|
111
|
+
AND status = 'failed'
|
|
112
|
+
AND error IS NOT NULL
|
|
113
|
+
ORDER BY failed_at DESC
|
|
114
|
+
LIMIT ?`,
|
|
115
|
+
[batchId, limit]
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function getStuckTaskCount(db: Database, batchId: string): number {
|
|
120
|
+
const rows = db.query<{ count: number }>(
|
|
121
|
+
`SELECT COUNT(*) as count
|
|
122
|
+
FROM task
|
|
123
|
+
WHERE batch_id = ?
|
|
124
|
+
AND status = 'running'
|
|
125
|
+
AND claimed_at < datetime('now', '-5 minutes')`,
|
|
126
|
+
[batchId]
|
|
127
|
+
);
|
|
128
|
+
return rows[0]?.count ?? 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function determineBatchStatus(summary: BatchSummary): string {
|
|
132
|
+
if (summary.failed > 0 && summary.pending === 0 && summary.running === 0) {
|
|
133
|
+
return 'failed';
|
|
134
|
+
}
|
|
135
|
+
if (summary.pending === 0 && summary.running === 0) {
|
|
136
|
+
return 'completed';
|
|
137
|
+
}
|
|
138
|
+
if (summary.running > 0) {
|
|
139
|
+
return 'running';
|
|
140
|
+
}
|
|
141
|
+
return 'pending';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function formatElapsed(startTime: number): string {
|
|
145
|
+
const elapsed = Date.now() - startTime;
|
|
146
|
+
return formatDuration(elapsed);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function calculateETA(
|
|
150
|
+
summary: BatchSummary,
|
|
151
|
+
avgTaskTimeMs: number | null,
|
|
152
|
+
state: WatchState
|
|
153
|
+
): string {
|
|
154
|
+
const remaining = summary.pending + summary.running;
|
|
155
|
+
if (remaining === 0) return 'Complete';
|
|
156
|
+
if (!avgTaskTimeMs || avgTaskTimeMs <= 0) return 'Calculating...';
|
|
157
|
+
|
|
158
|
+
// Estimate based on throughput since watch started
|
|
159
|
+
const elapsed = Date.now() - state.startTime;
|
|
160
|
+
const completedSinceStart = summary.completed - state.completedCount;
|
|
161
|
+
|
|
162
|
+
if (completedSinceStart > 0 && elapsed > 5000) {
|
|
163
|
+
const throughput = completedSinceStart / (elapsed / 1000);
|
|
164
|
+
const etaSeconds = remaining / throughput;
|
|
165
|
+
return formatDuration(Math.round(etaSeconds * 1000));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Fallback to average task time
|
|
169
|
+
const etaMs = remaining * avgTaskTimeMs;
|
|
170
|
+
return formatDuration(Math.round(etaMs));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function calculateThroughput(summary: BatchSummary, state: WatchState): string {
|
|
174
|
+
const elapsed = Date.now() - state.startTime;
|
|
175
|
+
if (elapsed < 1000) return '-';
|
|
176
|
+
|
|
177
|
+
const completedSinceStart = summary.completed - state.completedCount;
|
|
178
|
+
const throughput = completedSinceStart / (elapsed / 1000);
|
|
179
|
+
|
|
180
|
+
if (throughput < 1) {
|
|
181
|
+
return `${(1 / throughput).toFixed(1)}s/task`;
|
|
182
|
+
}
|
|
183
|
+
return `${throughput.toFixed(2)} tasks/sec`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function renderProgressBar(completed: number, failed: number, total: number, width: number = 40): string {
|
|
187
|
+
if (total === 0) return `[${' '.repeat(width)}] 0%`;
|
|
188
|
+
|
|
189
|
+
const done = completed + failed;
|
|
190
|
+
const percent = (done / total) * 100;
|
|
191
|
+
const filled = Math.round((done / total) * width);
|
|
192
|
+
const failedCount = Math.round((failed / total) * width);
|
|
193
|
+
const completedCount = filled - failedCount;
|
|
194
|
+
|
|
195
|
+
const bar = '█'.repeat(completedCount) + '▓'.repeat(failedCount) + '░'.repeat(width - filled);
|
|
196
|
+
return `[${bar}] ${percent.toFixed(1)}%`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getStatusColor(status: string): string {
|
|
200
|
+
switch (status) {
|
|
201
|
+
case 'completed':
|
|
202
|
+
return '\x1b[32m'; // Green
|
|
203
|
+
case 'failed':
|
|
204
|
+
return '\x1b[31m'; // Red
|
|
205
|
+
case 'running':
|
|
206
|
+
return '\x1b[33m'; // Yellow
|
|
207
|
+
default:
|
|
208
|
+
return '\x1b[36m'; // Cyan
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function resetColor(): string {
|
|
213
|
+
return '\x1b[0m';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function clearScreen(): void {
|
|
217
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function shouldContinueWatching(summary: BatchSummary): boolean {
|
|
221
|
+
const status = determineBatchStatus(summary);
|
|
222
|
+
return status !== 'completed' && status !== 'failed';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function renderWatchDisplay(
|
|
226
|
+
summary: BatchSummary,
|
|
227
|
+
timing: TaskTiming | null,
|
|
228
|
+
recentErrors: RecentError[],
|
|
229
|
+
stuckCount: number,
|
|
230
|
+
state: WatchState,
|
|
231
|
+
_options: WatchOptions
|
|
232
|
+
): string {
|
|
233
|
+
const lines: string[] = [];
|
|
234
|
+
const status = determineBatchStatus(summary);
|
|
235
|
+
const statusColor = getStatusColor(status);
|
|
236
|
+
const elapsed = Date.now() - new Date(summary.created_at).getTime();
|
|
237
|
+
|
|
238
|
+
// Header
|
|
239
|
+
lines.push('╔══════════════════════════════════════════════════════════════════╗');
|
|
240
|
+
lines.push(`║ TEM Batch Watch${' '.repeat(51)}║`);
|
|
241
|
+
lines.push('╠══════════════════════════════════════════════════════════════════╣');
|
|
242
|
+
|
|
243
|
+
// Batch info
|
|
244
|
+
const headerData = [
|
|
245
|
+
{ key: 'Batch', value: summary.code },
|
|
246
|
+
{ key: 'Type', value: summary.type },
|
|
247
|
+
{ key: 'Status', value: `${statusColor}${status.toUpperCase()}${resetColor()}` },
|
|
248
|
+
{ key: 'Elapsed', value: formatDuration(elapsed) },
|
|
249
|
+
{ key: 'Watching', value: formatElapsed(state.startTime) },
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
for (const item of headerData) {
|
|
253
|
+
const line = `║ ${(item.key + ':').padEnd(12)} ${item.value.toString().padEnd(52)}║`;
|
|
254
|
+
lines.push(line);
|
|
255
|
+
}
|
|
256
|
+
lines.push('╠══════════════════════════════════════════════════════════════════╣');
|
|
257
|
+
|
|
258
|
+
// Progress bar
|
|
259
|
+
lines.push('║ Progress ║');
|
|
260
|
+
lines.push(`║ ${renderProgressBar(summary.completed, summary.failed, summary.total).padEnd(64)}║`);
|
|
261
|
+
lines.push('╠══════════════════════════════════════════════════════════════════╣');
|
|
262
|
+
|
|
263
|
+
// Stats table
|
|
264
|
+
lines.push('║ Statistics ║');
|
|
265
|
+
const total = summary.total || 1;
|
|
266
|
+
const statsData = [
|
|
267
|
+
{ status: 'Pending', count: summary.pending, percent: formatPercent(summary.pending, total) },
|
|
268
|
+
{ status: 'Running', count: summary.running, percent: formatPercent(summary.running, total) },
|
|
269
|
+
{ status: 'Completed', count: summary.completed, percent: formatPercent(summary.completed, total) },
|
|
270
|
+
{ status: 'Failed', count: summary.failed, percent: formatPercent(summary.failed, total) },
|
|
271
|
+
{ status: 'Total', count: summary.total, percent: '100%' },
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
for (const stat of statsData) {
|
|
275
|
+
const line = `║ ${stat.status.padEnd(12)} ${formatNumber(stat.count).padStart(10)} ${stat.percent.padStart(6)}${' '.repeat(28)}║`;
|
|
276
|
+
lines.push(line);
|
|
277
|
+
}
|
|
278
|
+
lines.push('╠══════════════════════════════════════════════════════════════════╣');
|
|
279
|
+
|
|
280
|
+
// Timing info
|
|
281
|
+
lines.push('║ Performance ║');
|
|
282
|
+
const throughput = calculateThroughput(summary, state);
|
|
283
|
+
const eta = calculateETA(summary, timing?.avg_time ?? null, state);
|
|
284
|
+
const perfData = [
|
|
285
|
+
{ key: 'Throughput', value: throughput },
|
|
286
|
+
{ key: 'ETA', value: eta },
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
if (timing?.avg_time) {
|
|
290
|
+
perfData.push({ key: 'Avg Task', value: formatDuration(Math.round(timing.avg_time)) });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const item of perfData) {
|
|
294
|
+
const line = `║ ${(item.key + ':').padEnd(12)} ${item.value.toString().padEnd(52)}║`;
|
|
295
|
+
lines.push(line);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Stuck tasks warning
|
|
299
|
+
if (stuckCount > 0) {
|
|
300
|
+
lines.push('╠══════════════════════════════════════════════════════════════════╣');
|
|
301
|
+
lines.push(`║ ⚠ WARNING: ${stuckCount} task(s) stuck > 5 minutes${' '.repeat(37)}║`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Recent errors
|
|
305
|
+
if (recentErrors.length > 0) {
|
|
306
|
+
lines.push('╠══════════════════════════════════════════════════════════════════╣');
|
|
307
|
+
lines.push('║ Recent Failures ║');
|
|
308
|
+
for (const error of recentErrors.slice(0, 3)) {
|
|
309
|
+
const errorMsg = truncate(error.error, 58);
|
|
310
|
+
const line = `║ • ${errorMsg.padEnd(60)}║`;
|
|
311
|
+
lines.push(line);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
lines.push('╚══════════════════════════════════════════════════════════════════╝');
|
|
316
|
+
lines.push(`Last update: ${new Date().toISOString()}`);
|
|
317
|
+
|
|
318
|
+
return lines.join('\n');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function renderFinalReport(
|
|
322
|
+
summary: BatchSummary,
|
|
323
|
+
timing: TaskTiming | null,
|
|
324
|
+
recentErrors: RecentError[],
|
|
325
|
+
stuckCount: number,
|
|
326
|
+
state: WatchState
|
|
327
|
+
): string {
|
|
328
|
+
const lines: string[] = [];
|
|
329
|
+
const status = determineBatchStatus(summary);
|
|
330
|
+
const elapsed = Date.now() - new Date(summary.created_at).getTime();
|
|
331
|
+
|
|
332
|
+
lines.push('');
|
|
333
|
+
lines.push('════════════════════════════════════════════════════════════════════');
|
|
334
|
+
lines.push(' BATCH COMPLETED - FINAL REPORT');
|
|
335
|
+
lines.push('════════════════════════════════════════════════════════════════════');
|
|
336
|
+
lines.push('');
|
|
337
|
+
|
|
338
|
+
// Overview
|
|
339
|
+
lines.push('Overview');
|
|
340
|
+
const overviewData = [
|
|
341
|
+
{ key: 'Batch Code', value: summary.code },
|
|
342
|
+
{ key: 'Type', value: summary.type },
|
|
343
|
+
{ key: 'Status', value: status },
|
|
344
|
+
{ key: 'Created', value: formatTimestamp(summary.created_at) },
|
|
345
|
+
{ key: 'Duration', value: formatDuration(elapsed) },
|
|
346
|
+
];
|
|
347
|
+
lines.push(renderKeyValue(overviewData));
|
|
348
|
+
lines.push('');
|
|
349
|
+
|
|
350
|
+
// Status breakdown
|
|
351
|
+
lines.push('Status Breakdown');
|
|
352
|
+
const total = summary.total || 1;
|
|
353
|
+
const breakdownData = [
|
|
354
|
+
{ status: 'Total', count: formatNumber(summary.total), percent: '100%' },
|
|
355
|
+
{ status: 'Pending', count: formatNumber(summary.pending), percent: formatPercent(summary.pending, total) },
|
|
356
|
+
{ status: 'Running', count: formatNumber(summary.running), percent: formatPercent(summary.running, total) },
|
|
357
|
+
{ status: 'Completed', count: formatNumber(summary.completed), percent: formatPercent(summary.completed, total) },
|
|
358
|
+
{ status: 'Failed', count: formatNumber(summary.failed), percent: formatPercent(summary.failed, total) },
|
|
359
|
+
];
|
|
360
|
+
lines.push(renderTable(
|
|
361
|
+
[
|
|
362
|
+
{ header: 'Status', key: 'status', width: 12 },
|
|
363
|
+
{ header: 'Count', key: 'count', width: 10, align: 'right' },
|
|
364
|
+
{ header: 'Percent', key: 'percent', width: 10, align: 'right' },
|
|
365
|
+
],
|
|
366
|
+
breakdownData
|
|
367
|
+
));
|
|
368
|
+
|
|
369
|
+
// Timing
|
|
370
|
+
if (timing && timing.avg_time !== null) {
|
|
371
|
+
lines.push('');
|
|
372
|
+
lines.push('Timing Analysis');
|
|
373
|
+
const elapsedWatch = Date.now() - state.startTime;
|
|
374
|
+
const completedSinceStart = summary.completed - state.completedCount;
|
|
375
|
+
let throughput = '-';
|
|
376
|
+
if (elapsedWatch > 0 && completedSinceStart > 0) {
|
|
377
|
+
const tps = completedSinceStart / (elapsedWatch / 1000);
|
|
378
|
+
throughput = tps < 1 ? `${(1 / tps).toFixed(1)}s/task` : `${tps.toFixed(2)} tasks/sec`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const timingData = [
|
|
382
|
+
{ key: 'Avg Task Time', value: formatDuration(Math.round(timing.avg_time)) },
|
|
383
|
+
{ key: 'Throughput', value: throughput },
|
|
384
|
+
];
|
|
385
|
+
lines.push(renderKeyValue(timingData));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Stuck tasks
|
|
389
|
+
if (stuckCount > 0) {
|
|
390
|
+
lines.push('');
|
|
391
|
+
lines.push(`⚠ WARNING: ${stuckCount} task(s) were stuck > 5 minutes`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Errors
|
|
395
|
+
if (recentErrors.length > 0) {
|
|
396
|
+
lines.push('');
|
|
397
|
+
lines.push('Recent Failures');
|
|
398
|
+
const errorData = recentErrors.slice(0, 5).map(e => ({
|
|
399
|
+
time: formatTimestamp(e.failed_at).slice(0, 19),
|
|
400
|
+
error: truncate(e.error, 60),
|
|
401
|
+
}));
|
|
402
|
+
lines.push(renderTable(
|
|
403
|
+
[
|
|
404
|
+
{ header: 'Time', key: 'time', width: 20 },
|
|
405
|
+
{ header: 'Error', key: 'error' },
|
|
406
|
+
],
|
|
407
|
+
errorData
|
|
408
|
+
));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
lines.push('');
|
|
412
|
+
lines.push('════════════════════════════════════════════════════════════════════');
|
|
413
|
+
|
|
414
|
+
return lines.join('\n');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export async function watchCommand(
|
|
418
|
+
dbPath: string,
|
|
419
|
+
batchCode: string | undefined,
|
|
420
|
+
flags: Record<string, string | boolean>
|
|
421
|
+
): Promise<void> {
|
|
422
|
+
const options: WatchOptions = {
|
|
423
|
+
interval: (parseInt(String(flags['interval'] || '5'), 10) || 5) * 1000,
|
|
424
|
+
timeout: (parseInt(String(flags['timeout'] || '3600'), 10) || 3600) * 1000,
|
|
425
|
+
noClear: flags['no-clear'] === true,
|
|
426
|
+
latest: flags['latest'] === true,
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const db = openDatabase(dbPath);
|
|
430
|
+
|
|
431
|
+
// Resolve batch
|
|
432
|
+
let batchId: string;
|
|
433
|
+
let resolvedBatchCode: string;
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
if (batchCode) {
|
|
437
|
+
const batch = getBatchByCode(db, batchCode);
|
|
438
|
+
if (!batch) {
|
|
439
|
+
console.error(`Error: Batch "${batchCode}" not found`);
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
batchId = batch.id;
|
|
443
|
+
resolvedBatchCode = batchCode;
|
|
444
|
+
} else if (options.latest) {
|
|
445
|
+
const batch = getLatestBatch(db);
|
|
446
|
+
if (!batch) {
|
|
447
|
+
console.error('Error: No batches found in database');
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
batchId = batch.id;
|
|
451
|
+
resolvedBatchCode = batch.code;
|
|
452
|
+
} else {
|
|
453
|
+
console.error('Error: Either provide a batch-code or use --latest flag');
|
|
454
|
+
process.exit(2);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Get initial summary
|
|
458
|
+
let summary = getBatchSummary(db, batchId);
|
|
459
|
+
if (!summary) {
|
|
460
|
+
console.error(`Error: Could not load summary for batch "${resolvedBatchCode}"`);
|
|
461
|
+
process.exit(1);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// If already completed/failed, just print report and exit
|
|
465
|
+
const initialStatus = determineBatchStatus(summary);
|
|
466
|
+
if (initialStatus === 'completed' || initialStatus === 'failed') {
|
|
467
|
+
const timing = getTaskTiming(db, batchId);
|
|
468
|
+
const recentErrors = getRecentErrors(db, batchId, 5);
|
|
469
|
+
const stuckCount = getStuckTaskCount(db, batchId);
|
|
470
|
+
const state: WatchState = {
|
|
471
|
+
startTime: Date.now(),
|
|
472
|
+
lastUpdateTime: Date.now(),
|
|
473
|
+
completedCount: summary.completed,
|
|
474
|
+
shouldExit: false,
|
|
475
|
+
exitCode: 0,
|
|
476
|
+
};
|
|
477
|
+
console.log(renderFinalReport(summary, timing, recentErrors, stuckCount, state));
|
|
478
|
+
process.exit(0);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Initialize watch state
|
|
482
|
+
const state: WatchState = {
|
|
483
|
+
startTime: Date.now(),
|
|
484
|
+
lastUpdateTime: Date.now(),
|
|
485
|
+
completedCount: summary.completed,
|
|
486
|
+
shouldExit: false,
|
|
487
|
+
exitCode: 0,
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
// Set up SIGINT handler
|
|
491
|
+
let sigintReceived = false;
|
|
492
|
+
process.on('SIGINT', () => {
|
|
493
|
+
if (sigintReceived) {
|
|
494
|
+
process.exit(130);
|
|
495
|
+
}
|
|
496
|
+
sigintReceived = true;
|
|
497
|
+
state.shouldExit = true;
|
|
498
|
+
state.exitCode = 130;
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Initial render
|
|
502
|
+
if (!options.noClear) clearScreen();
|
|
503
|
+
const timing = getTaskTiming(db, batchId);
|
|
504
|
+
const recentErrors = getRecentErrors(db, batchId, 3);
|
|
505
|
+
const stuckCount = getStuckTaskCount(db, batchId);
|
|
506
|
+
console.log(renderWatchDisplay(summary, timing, recentErrors, stuckCount, state, options));
|
|
507
|
+
|
|
508
|
+
// Watch loop
|
|
509
|
+
return new Promise((_resolve) => {
|
|
510
|
+
const intervalId = setInterval(() => {
|
|
511
|
+
// Check timeout
|
|
512
|
+
if (Date.now() - state.startTime > options.timeout) {
|
|
513
|
+
clearInterval(intervalId);
|
|
514
|
+
if (!options.noClear) clearScreen();
|
|
515
|
+
console.error('Watch timeout reached');
|
|
516
|
+
db.close();
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Check if should exit
|
|
521
|
+
if (state.shouldExit) {
|
|
522
|
+
clearInterval(intervalId);
|
|
523
|
+
if (!options.noClear) clearScreen();
|
|
524
|
+
console.log('\nWatch interrupted by user');
|
|
525
|
+
db.close();
|
|
526
|
+
process.exit(state.exitCode);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Refresh data
|
|
530
|
+
summary = getBatchSummary(db, batchId);
|
|
531
|
+
if (!summary) {
|
|
532
|
+
clearInterval(intervalId);
|
|
533
|
+
console.error('Error: Could not refresh batch data');
|
|
534
|
+
db.close();
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Render update
|
|
539
|
+
if (!options.noClear) clearScreen();
|
|
540
|
+
const timing = getTaskTiming(db, batchId);
|
|
541
|
+
const recentErrors = getRecentErrors(db, batchId, 3);
|
|
542
|
+
const stuckCount = getStuckTaskCount(db, batchId);
|
|
543
|
+
console.log(renderWatchDisplay(summary, timing, recentErrors, stuckCount, state, options));
|
|
544
|
+
|
|
545
|
+
// Check if batch is done
|
|
546
|
+
if (!shouldContinueWatching(summary)) {
|
|
547
|
+
clearInterval(intervalId);
|
|
548
|
+
if (!options.noClear) clearScreen();
|
|
549
|
+
console.log(renderFinalReport(summary, timing, recentErrors, stuckCount, state));
|
|
550
|
+
db.close();
|
|
551
|
+
process.exit(0);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
state.lastUpdateTime = Date.now();
|
|
555
|
+
}, options.interval);
|
|
556
|
+
});
|
|
557
|
+
} catch (error) {
|
|
558
|
+
db.close();
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { reportCommand } from './commands/report.js';
|
|
3
|
+
import { listCommand } from './commands/list.js';
|
|
4
|
+
import { watchCommand } from './commands/watch.js';
|
|
5
|
+
|
|
6
|
+
const HELP_TEXT = `tem CLI - Batch diagnostics and reporting tool
|
|
7
|
+
|
|
8
|
+
Usage: tem <command> [options]
|
|
9
|
+
|
|
10
|
+
Commands:
|
|
11
|
+
report <db-path> [batch-code] Generate diagnostic report
|
|
12
|
+
list <db-path> List tasks with filtering
|
|
13
|
+
watch <db-path> [batch-code] Monitor a running batch
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--help, -h Show this help message
|
|
17
|
+
|
|
18
|
+
Report command options:
|
|
19
|
+
--latest Use the most recently created batch
|
|
20
|
+
--limit-errors N Show top N error patterns (default: 10)
|
|
21
|
+
|
|
22
|
+
List command options:
|
|
23
|
+
--batch <code> Filter by batch code
|
|
24
|
+
--status <status> Filter by status (pending|running|completed|failed)
|
|
25
|
+
--type <type> Filter by task type
|
|
26
|
+
--limit <n> Limit results (default: 100)
|
|
27
|
+
|
|
28
|
+
Watch command options:
|
|
29
|
+
--latest Use the most recently created batch
|
|
30
|
+
--interval N Refresh interval in seconds (default: 5)
|
|
31
|
+
--timeout N Maximum watch time in seconds (default: 3600)
|
|
32
|
+
--no-clear Don't clear screen between updates
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
tem report ./test.db # Summary of all batches
|
|
36
|
+
tem report ./test.db my-batch # Detailed report for batch
|
|
37
|
+
tem report ./test.db --latest # Report for latest batch
|
|
38
|
+
tem list ./test.db --batch my-batch --status failed --limit 20
|
|
39
|
+
tem watch ./test.db --latest # Watch latest batch
|
|
40
|
+
tem watch ./test.db my-batch # Watch specific batch
|
|
41
|
+
tem watch ./test.db --latest --interval 10 --timeout 300
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
function showHelp(): void {
|
|
45
|
+
console.log(HELP_TEXT);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseArgs(args: string[]): {
|
|
49
|
+
command: string;
|
|
50
|
+
dbPath: string;
|
|
51
|
+
positional: string[];
|
|
52
|
+
flags: Record<string, string | boolean>;
|
|
53
|
+
} {
|
|
54
|
+
const flags: Record<string, string | boolean> = {};
|
|
55
|
+
const positional: string[] = [];
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < args.length; i++) {
|
|
58
|
+
const arg = args[i];
|
|
59
|
+
|
|
60
|
+
if (arg === '--help' || arg === '-h') {
|
|
61
|
+
flags.help = true;
|
|
62
|
+
} else if (arg === '--latest') {
|
|
63
|
+
flags.latest = true;
|
|
64
|
+
} else if (arg.startsWith('--')) {
|
|
65
|
+
const key = arg.slice(2);
|
|
66
|
+
const nextArg = args[i + 1];
|
|
67
|
+
if (nextArg && !nextArg.startsWith('--')) {
|
|
68
|
+
flags[key] = nextArg;
|
|
69
|
+
i++;
|
|
70
|
+
} else {
|
|
71
|
+
flags[key] = true;
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
positional.push(arg);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
command: positional[0] || '',
|
|
80
|
+
dbPath: positional[1] || '',
|
|
81
|
+
positional: positional.slice(2),
|
|
82
|
+
flags,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function main(): Promise<void> {
|
|
87
|
+
const args = Bun.argv.slice(2);
|
|
88
|
+
|
|
89
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
90
|
+
showHelp();
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { command, dbPath, positional, flags } = parseArgs(args);
|
|
95
|
+
|
|
96
|
+
if (flags.help) {
|
|
97
|
+
showHelp();
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!command) {
|
|
102
|
+
console.error('Error: No command specified');
|
|
103
|
+
showHelp();
|
|
104
|
+
process.exit(2);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!dbPath) {
|
|
108
|
+
console.error('Error: Database path required');
|
|
109
|
+
showHelp();
|
|
110
|
+
process.exit(2);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
switch (command) {
|
|
115
|
+
case 'report':
|
|
116
|
+
await reportCommand(dbPath, positional[0], flags);
|
|
117
|
+
break;
|
|
118
|
+
case 'list':
|
|
119
|
+
await listCommand(dbPath, flags);
|
|
120
|
+
break;
|
|
121
|
+
case 'watch':
|
|
122
|
+
await watchCommand(dbPath, positional[0], flags);
|
|
123
|
+
break;
|
|
124
|
+
default:
|
|
125
|
+
console.error(`Error: Unknown command "${command}"`);
|
|
126
|
+
showHelp();
|
|
127
|
+
process.exit(2);
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error('Error:', error instanceof Error ? error.message : String(error));
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
main();
|