@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
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qianxude/tem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A lightweight task execution engine for IO-bound workloads with SQLite persistence, retry, and rate limiting",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"tem": "./src/cli/index.ts"
|
|
9
|
+
},
|
|
7
10
|
"exports": {
|
|
8
11
|
".": "./src/index.ts"
|
|
9
12
|
},
|
|
@@ -26,7 +29,9 @@
|
|
|
26
29
|
"test:auto-detect": "bun test tests/integration/auto-detect.test.ts",
|
|
27
30
|
"lint": "oxlint",
|
|
28
31
|
"lint:file": "oxlint",
|
|
32
|
+
"example:llm-detect": "bun examples/llm-detect.ts",
|
|
29
33
|
"dev": "bun --watch src/index.ts",
|
|
34
|
+
"cli": "bun ./src/cli/index.ts",
|
|
30
35
|
"publish:pkg": "bun publish --access public",
|
|
31
36
|
"version:patch": "./scripts/version.sh patch",
|
|
32
37
|
"version:minor": "./scripts/version.sh minor",
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Database } from '../../database/index.js';
|
|
2
|
+
import * as i from '../../interfaces/index.js';
|
|
3
|
+
import { formatTimestamp, truncate } from '../utils/format.js';
|
|
4
|
+
import { renderTable } from '../utils/table.js';
|
|
5
|
+
|
|
6
|
+
interface TaskRow {
|
|
7
|
+
id: string;
|
|
8
|
+
batch_id: string | null;
|
|
9
|
+
batch_code: string | null;
|
|
10
|
+
type: string;
|
|
11
|
+
status: i.TaskStatus;
|
|
12
|
+
attempt: number;
|
|
13
|
+
max_attempt: number;
|
|
14
|
+
created_at: string;
|
|
15
|
+
completed_at: string | null;
|
|
16
|
+
error: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function openDatabase(dbPath: string): Database {
|
|
20
|
+
return new Database({ path: dbPath, busyTimeout: 5000 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildTaskQuery(
|
|
24
|
+
db: Database,
|
|
25
|
+
filters: {
|
|
26
|
+
batchCode?: string;
|
|
27
|
+
status?: string;
|
|
28
|
+
type?: string;
|
|
29
|
+
limit: number;
|
|
30
|
+
}
|
|
31
|
+
): TaskRow[] {
|
|
32
|
+
const conditions: string[] = [];
|
|
33
|
+
const params: (string | number)[] = [];
|
|
34
|
+
|
|
35
|
+
if (filters.batchCode) {
|
|
36
|
+
conditions.push('b.code = ?');
|
|
37
|
+
params.push(filters.batchCode);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (filters.status) {
|
|
41
|
+
conditions.push('t.status = ?');
|
|
42
|
+
params.push(filters.status);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (filters.type) {
|
|
46
|
+
conditions.push('t.type = ?');
|
|
47
|
+
params.push(filters.type);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
51
|
+
|
|
52
|
+
const sql = `
|
|
53
|
+
SELECT
|
|
54
|
+
t.id,
|
|
55
|
+
t.batch_id,
|
|
56
|
+
b.code as batch_code,
|
|
57
|
+
t.type,
|
|
58
|
+
t.status,
|
|
59
|
+
t.attempt,
|
|
60
|
+
t.max_attempt,
|
|
61
|
+
t.created_at,
|
|
62
|
+
t.completed_at,
|
|
63
|
+
t.error
|
|
64
|
+
FROM task t
|
|
65
|
+
LEFT JOIN batch b ON b.id = t.batch_id
|
|
66
|
+
${whereClause}
|
|
67
|
+
ORDER BY t.created_at DESC
|
|
68
|
+
LIMIT ?
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
params.push(filters.limit);
|
|
72
|
+
|
|
73
|
+
return db.query<TaskRow>(sql, params);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function listCommand(
|
|
77
|
+
dbPath: string,
|
|
78
|
+
flags: Record<string, string | boolean>
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
const batchCode = String(flags.batch || '');
|
|
81
|
+
const status = String(flags.status || '');
|
|
82
|
+
const type = String(flags.type || '');
|
|
83
|
+
const limit = parseInt(String(flags.limit || '100'), 10) || 100;
|
|
84
|
+
|
|
85
|
+
// Validate status if provided
|
|
86
|
+
const validStatuses: i.TaskStatus[] = ['pending', 'running', 'completed', 'failed'];
|
|
87
|
+
if (status && !validStatuses.includes(status as i.TaskStatus)) {
|
|
88
|
+
console.error(
|
|
89
|
+
`Error: Invalid status "${status}". Valid values: ${validStatuses.join(', ')}`
|
|
90
|
+
);
|
|
91
|
+
process.exit(2);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const db = openDatabase(dbPath);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const tasks = buildTaskQuery(db, {
|
|
98
|
+
batchCode: batchCode || undefined,
|
|
99
|
+
status: status || undefined,
|
|
100
|
+
type: type || undefined,
|
|
101
|
+
limit,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (tasks.length === 0) {
|
|
105
|
+
console.log('No tasks found matching the criteria.');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(`Found ${tasks.length} task(s)`);
|
|
110
|
+
console.log();
|
|
111
|
+
|
|
112
|
+
const rows = tasks.map((t) => ({
|
|
113
|
+
id: t.id.slice(0, 22),
|
|
114
|
+
batch: t.batch_code || '-',
|
|
115
|
+
type: truncate(t.type, 20),
|
|
116
|
+
status: t.status,
|
|
117
|
+
attempt: `${t.attempt}/${t.max_attempt}`,
|
|
118
|
+
created: formatTimestamp(t.created_at).slice(0, 19),
|
|
119
|
+
completed: t.completed_at ? formatTimestamp(t.completed_at).slice(0, 19) : '-',
|
|
120
|
+
error: truncate(t.error, 30),
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
console.log(
|
|
124
|
+
renderTable(
|
|
125
|
+
[
|
|
126
|
+
{ header: 'ID', key: 'id', width: 24 },
|
|
127
|
+
{ header: 'Batch', key: 'batch', width: 16 },
|
|
128
|
+
{ header: 'Type', key: 'type', width: 22 },
|
|
129
|
+
{ header: 'Status', key: 'status', width: 10 },
|
|
130
|
+
{ header: 'Attempt', key: 'attempt', width: 8, align: 'right' },
|
|
131
|
+
{ header: 'Created', key: 'created', width: 20 },
|
|
132
|
+
{ header: 'Completed', key: 'completed', width: 20 },
|
|
133
|
+
{ header: 'Error', key: 'error', width: 32 },
|
|
134
|
+
],
|
|
135
|
+
rows
|
|
136
|
+
)
|
|
137
|
+
);
|
|
138
|
+
} finally {
|
|
139
|
+
db.close();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,453 @@
|
|
|
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
|
+
export 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
|
+
export interface TaskTiming {
|
|
25
|
+
avg_time: number;
|
|
26
|
+
min_time: number;
|
|
27
|
+
max_time: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ErrorPattern {
|
|
31
|
+
error: string;
|
|
32
|
+
count: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface StuckTask {
|
|
36
|
+
id: string;
|
|
37
|
+
type: string;
|
|
38
|
+
claimed_at: string;
|
|
39
|
+
attempt: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function openDatabase(dbPath: string): Database {
|
|
43
|
+
return new Database({ path: dbPath, busyTimeout: 5000 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getBatchSummary(db: Database, batchId: string): BatchSummary | null {
|
|
47
|
+
const rows = db.query<BatchSummary>(
|
|
48
|
+
`SELECT
|
|
49
|
+
b.id,
|
|
50
|
+
b.code,
|
|
51
|
+
b.type,
|
|
52
|
+
b.created_at,
|
|
53
|
+
b.completed_at,
|
|
54
|
+
COUNT(t.id) as total,
|
|
55
|
+
SUM(CASE WHEN t.status = 'pending' THEN 1 ELSE 0 END) as pending,
|
|
56
|
+
SUM(CASE WHEN t.status = 'running' THEN 1 ELSE 0 END) as running,
|
|
57
|
+
SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed,
|
|
58
|
+
SUM(CASE WHEN t.status = 'failed' THEN 1 ELSE 0 END) as failed
|
|
59
|
+
FROM batch b
|
|
60
|
+
LEFT JOIN task t ON t.batch_id = b.id
|
|
61
|
+
WHERE b.id = ?
|
|
62
|
+
GROUP BY b.id`,
|
|
63
|
+
[batchId]
|
|
64
|
+
);
|
|
65
|
+
return rows[0] || null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getBatchByCode(db: Database, code: string): { id: string } | null {
|
|
69
|
+
const rows = db.query<{ id: string }>(
|
|
70
|
+
'SELECT id FROM batch WHERE code = ?',
|
|
71
|
+
[code]
|
|
72
|
+
);
|
|
73
|
+
return rows[0] || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getLatestBatch(db: Database): { id: string; code: string } | null {
|
|
77
|
+
const rows = db.query<{ id: string; code: string }>(
|
|
78
|
+
'SELECT id, code FROM batch ORDER BY created_at DESC LIMIT 1'
|
|
79
|
+
);
|
|
80
|
+
return rows[0] || null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getAllBatchesSummary(db: Database): BatchSummary[] {
|
|
84
|
+
return db.query<BatchSummary>(
|
|
85
|
+
`SELECT
|
|
86
|
+
b.id,
|
|
87
|
+
b.code,
|
|
88
|
+
b.type,
|
|
89
|
+
b.created_at,
|
|
90
|
+
b.completed_at,
|
|
91
|
+
COUNT(t.id) as total,
|
|
92
|
+
SUM(CASE WHEN t.status = 'pending' THEN 1 ELSE 0 END) as pending,
|
|
93
|
+
SUM(CASE WHEN t.status = 'running' THEN 1 ELSE 0 END) as running,
|
|
94
|
+
SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed,
|
|
95
|
+
SUM(CASE WHEN t.status = 'failed' THEN 1 ELSE 0 END) as failed
|
|
96
|
+
FROM batch b
|
|
97
|
+
LEFT JOIN task t ON t.batch_id = b.id
|
|
98
|
+
GROUP BY b.id
|
|
99
|
+
ORDER BY b.created_at DESC`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getTaskTiming(db: Database, batchId: string): TaskTiming | null {
|
|
104
|
+
const rows = db.query<TaskTiming>(
|
|
105
|
+
`SELECT
|
|
106
|
+
AVG(
|
|
107
|
+
unixepoch(completed_at) - unixepoch(created_at)
|
|
108
|
+
) * 1000 as avg_time,
|
|
109
|
+
MIN(
|
|
110
|
+
unixepoch(completed_at) - unixepoch(created_at)
|
|
111
|
+
) * 1000 as min_time,
|
|
112
|
+
MAX(
|
|
113
|
+
unixepoch(completed_at) - unixepoch(created_at)
|
|
114
|
+
) * 1000 as max_time
|
|
115
|
+
FROM task
|
|
116
|
+
WHERE batch_id = ?
|
|
117
|
+
AND status = 'completed'
|
|
118
|
+
AND completed_at IS NOT NULL`,
|
|
119
|
+
[batchId]
|
|
120
|
+
);
|
|
121
|
+
return rows[0] || null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getErrorPatterns(
|
|
125
|
+
db: Database,
|
|
126
|
+
batchId: string,
|
|
127
|
+
limit: number
|
|
128
|
+
): ErrorPattern[] {
|
|
129
|
+
return db.query<ErrorPattern>(
|
|
130
|
+
`SELECT
|
|
131
|
+
error,
|
|
132
|
+
COUNT(*) as count
|
|
133
|
+
FROM task
|
|
134
|
+
WHERE batch_id = ?
|
|
135
|
+
AND status = 'failed'
|
|
136
|
+
AND error IS NOT NULL
|
|
137
|
+
GROUP BY error
|
|
138
|
+
ORDER BY count DESC
|
|
139
|
+
LIMIT ?`,
|
|
140
|
+
[batchId, limit]
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getRetryStats(
|
|
145
|
+
db: Database,
|
|
146
|
+
batchId: string
|
|
147
|
+
): { total_retries: number; tasks_with_retries: number } | null {
|
|
148
|
+
const rows = db.query<{ total_retries: number; tasks_with_retries: number }>(
|
|
149
|
+
`SELECT
|
|
150
|
+
SUM(attempt) as total_retries,
|
|
151
|
+
COUNT(CASE WHEN attempt > 1 THEN 1 END) as tasks_with_retries
|
|
152
|
+
FROM task
|
|
153
|
+
WHERE batch_id = ?
|
|
154
|
+
AND status = 'completed'`,
|
|
155
|
+
[batchId]
|
|
156
|
+
);
|
|
157
|
+
return rows[0] || null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getStuckTasks(db: Database, batchId: string): StuckTask[] {
|
|
161
|
+
// Tasks that have been running for more than 5 minutes
|
|
162
|
+
return db.query<StuckTask>(
|
|
163
|
+
`SELECT
|
|
164
|
+
id,
|
|
165
|
+
type,
|
|
166
|
+
claimed_at,
|
|
167
|
+
attempt
|
|
168
|
+
FROM task
|
|
169
|
+
WHERE batch_id = ?
|
|
170
|
+
AND status = 'running'
|
|
171
|
+
AND claimed_at < datetime('now', '-5 minutes')`,
|
|
172
|
+
[batchId]
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function determineBatchStatus(summary: BatchSummary): string {
|
|
177
|
+
if (summary.failed > 0 && summary.pending === 0 && summary.running === 0) {
|
|
178
|
+
return 'failed';
|
|
179
|
+
}
|
|
180
|
+
if (summary.pending === 0 && summary.running === 0) {
|
|
181
|
+
return 'completed';
|
|
182
|
+
}
|
|
183
|
+
if (summary.running > 0) {
|
|
184
|
+
return 'running';
|
|
185
|
+
}
|
|
186
|
+
return 'pending';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function printBatchSummary(summary: BatchSummary): void {
|
|
190
|
+
const status = determineBatchStatus(summary);
|
|
191
|
+
|
|
192
|
+
const overviewData = [
|
|
193
|
+
{ key: 'Batch Code', value: summary.code },
|
|
194
|
+
{ key: 'Type', value: summary.type },
|
|
195
|
+
{ key: 'Status', value: status },
|
|
196
|
+
{ key: 'Created', value: formatTimestamp(summary.created_at) },
|
|
197
|
+
{ key: 'Completed', value: formatTimestamp(summary.completed_at) },
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
if (summary.completed_at) {
|
|
201
|
+
const created = new Date(summary.created_at).getTime();
|
|
202
|
+
const completed = new Date(summary.completed_at).getTime();
|
|
203
|
+
overviewData.push({ key: 'Duration', value: formatDuration(completed - created) });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
console.log('Overview');
|
|
207
|
+
console.log(renderKeyValue(overviewData));
|
|
208
|
+
console.log();
|
|
209
|
+
|
|
210
|
+
// Status breakdown
|
|
211
|
+
const total = summary.total || 1; // Avoid division by zero
|
|
212
|
+
const breakdownData = [
|
|
213
|
+
{
|
|
214
|
+
status: 'Total',
|
|
215
|
+
count: formatNumber(summary.total),
|
|
216
|
+
percent: '100%',
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
status: 'Pending',
|
|
220
|
+
count: formatNumber(summary.pending),
|
|
221
|
+
percent: formatPercent(summary.pending, total),
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
status: 'Running',
|
|
225
|
+
count: formatNumber(summary.running),
|
|
226
|
+
percent: formatPercent(summary.running, total),
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
status: 'Completed',
|
|
230
|
+
count: formatNumber(summary.completed),
|
|
231
|
+
percent: formatPercent(summary.completed, total),
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
status: 'Failed',
|
|
235
|
+
count: formatNumber(summary.failed),
|
|
236
|
+
percent: formatPercent(summary.failed, total),
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
console.log('Status Breakdown');
|
|
241
|
+
console.log(
|
|
242
|
+
renderTable(
|
|
243
|
+
[
|
|
244
|
+
{ header: 'Status', key: 'status', width: 12 },
|
|
245
|
+
{ header: 'Count', key: 'count', width: 10, align: 'right' },
|
|
246
|
+
{ header: 'Percent', key: 'percent', width: 10, align: 'right' },
|
|
247
|
+
],
|
|
248
|
+
breakdownData
|
|
249
|
+
)
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function printDetailedReport(
|
|
254
|
+
db: Database,
|
|
255
|
+
summary: BatchSummary,
|
|
256
|
+
limitErrors: number
|
|
257
|
+
): void {
|
|
258
|
+
printBatchSummary(summary);
|
|
259
|
+
|
|
260
|
+
// Timing analysis
|
|
261
|
+
const timing = getTaskTiming(db, summary.id);
|
|
262
|
+
if (timing && timing.avg_time !== null) {
|
|
263
|
+
console.log();
|
|
264
|
+
console.log('Timing Analysis');
|
|
265
|
+
const timingData = [
|
|
266
|
+
{
|
|
267
|
+
key: 'Avg Task Time',
|
|
268
|
+
value: formatDuration(Math.round(timing.avg_time)),
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
key: 'Min Task Time',
|
|
272
|
+
value: formatDuration(Math.round(timing.min_time)),
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
key: 'Max Task Time',
|
|
276
|
+
value: formatDuration(Math.round(timing.max_time)),
|
|
277
|
+
},
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
// Calculate throughput
|
|
281
|
+
if (summary.completed > 0 && timing.avg_time > 0) {
|
|
282
|
+
const tasksPerSecond = 1000 / timing.avg_time;
|
|
283
|
+
timingData.push({
|
|
284
|
+
key: 'Throughput',
|
|
285
|
+
value: `${tasksPerSecond.toFixed(2)} tasks/sec`,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
console.log(renderKeyValue(timingData));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Failure analysis
|
|
293
|
+
if (summary.failed > 0) {
|
|
294
|
+
console.log();
|
|
295
|
+
console.log('Failure Analysis');
|
|
296
|
+
const errors = getErrorPatterns(db, summary.id, limitErrors);
|
|
297
|
+
if (errors.length > 0) {
|
|
298
|
+
console.log(
|
|
299
|
+
renderTable(
|
|
300
|
+
[
|
|
301
|
+
{ header: 'Count', key: 'count', width: 8, align: 'right' },
|
|
302
|
+
{ header: 'Error', key: 'error' },
|
|
303
|
+
],
|
|
304
|
+
errors.map((e) => ({
|
|
305
|
+
count: formatNumber(e.count),
|
|
306
|
+
error: truncate(e.error, 80),
|
|
307
|
+
}))
|
|
308
|
+
)
|
|
309
|
+
);
|
|
310
|
+
} else {
|
|
311
|
+
console.log('No error details available.');
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Retry analysis
|
|
316
|
+
const retryStats = getRetryStats(db, summary.id);
|
|
317
|
+
if (retryStats && retryStats.tasks_with_retries > 0) {
|
|
318
|
+
console.log();
|
|
319
|
+
console.log('Retry Analysis');
|
|
320
|
+
const retryData = [
|
|
321
|
+
{
|
|
322
|
+
key: 'Tasks with Retries',
|
|
323
|
+
value: formatNumber(retryStats.tasks_with_retries),
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
key: 'Total Retry Attempts',
|
|
327
|
+
value: formatNumber(retryStats.total_retries),
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
key: 'Retry Success Rate',
|
|
331
|
+
value: formatPercent(
|
|
332
|
+
retryStats.tasks_with_retries,
|
|
333
|
+
summary.completed
|
|
334
|
+
),
|
|
335
|
+
},
|
|
336
|
+
];
|
|
337
|
+
console.log(renderKeyValue(retryData));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Stuck task detection
|
|
341
|
+
const stuckTasks = getStuckTasks(db, summary.id);
|
|
342
|
+
if (stuckTasks.length > 0) {
|
|
343
|
+
console.log();
|
|
344
|
+
console.log('Stuck Task Detection (running > 5 minutes)');
|
|
345
|
+
console.log(
|
|
346
|
+
renderTable(
|
|
347
|
+
[
|
|
348
|
+
{ header: 'ID', key: 'id', width: 24 },
|
|
349
|
+
{ header: 'Type', key: 'type', width: 20 },
|
|
350
|
+
{ header: 'Claimed At', key: 'claimed_at', width: 24 },
|
|
351
|
+
{ header: 'Attempt', key: 'attempt', width: 8, align: 'right' },
|
|
352
|
+
],
|
|
353
|
+
stuckTasks.map((t) => ({
|
|
354
|
+
id: t.id.slice(0, 22),
|
|
355
|
+
type: t.type,
|
|
356
|
+
claimed_at: formatTimestamp(t.claimed_at),
|
|
357
|
+
attempt: t.attempt,
|
|
358
|
+
}))
|
|
359
|
+
)
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function printAllBatchesSummary(db: Database): void {
|
|
365
|
+
const batches = getAllBatchesSummary(db);
|
|
366
|
+
|
|
367
|
+
if (batches.length === 0) {
|
|
368
|
+
console.log('No batches found in database.');
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
console.log(`Found ${batches.length} batch(es)`);
|
|
373
|
+
console.log();
|
|
374
|
+
|
|
375
|
+
const rows = batches.map((b) => {
|
|
376
|
+
const status = determineBatchStatus(b);
|
|
377
|
+
return {
|
|
378
|
+
code: b.code,
|
|
379
|
+
type: b.type,
|
|
380
|
+
status,
|
|
381
|
+
created: formatTimestamp(b.created_at).slice(0, 19),
|
|
382
|
+
total: formatNumber(b.total),
|
|
383
|
+
pending: formatNumber(b.pending),
|
|
384
|
+
running: formatNumber(b.running),
|
|
385
|
+
completed: formatNumber(b.completed),
|
|
386
|
+
failed: formatNumber(b.failed),
|
|
387
|
+
};
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
console.log(
|
|
391
|
+
renderTable(
|
|
392
|
+
[
|
|
393
|
+
{ header: 'Code', key: 'code', width: 20 },
|
|
394
|
+
{ header: 'Type', key: 'type', width: 16 },
|
|
395
|
+
{ header: 'Status', key: 'status', width: 12 },
|
|
396
|
+
{ header: 'Created', key: 'created', width: 20 },
|
|
397
|
+
{ header: 'Total', key: 'total', width: 8, align: 'right' },
|
|
398
|
+
{ header: 'Pend', key: 'pending', width: 6, align: 'right' },
|
|
399
|
+
{ header: 'Run', key: 'running', width: 5, align: 'right' },
|
|
400
|
+
{ header: 'Done', key: 'completed', width: 6, align: 'right' },
|
|
401
|
+
{ header: 'Fail', key: 'failed', width: 5, align: 'right' },
|
|
402
|
+
],
|
|
403
|
+
rows
|
|
404
|
+
)
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export async function reportCommand(
|
|
409
|
+
dbPath: string,
|
|
410
|
+
batchCode: string | undefined,
|
|
411
|
+
flags: Record<string, string | boolean>
|
|
412
|
+
): Promise<void> {
|
|
413
|
+
const limitErrors = parseInt(String(flags['limit-errors'] || '10'), 10) || 10;
|
|
414
|
+
|
|
415
|
+
const db = openDatabase(dbPath);
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
if (batchCode) {
|
|
419
|
+
// Detailed report for specific batch
|
|
420
|
+
const batch = getBatchByCode(db, batchCode);
|
|
421
|
+
if (!batch) {
|
|
422
|
+
console.error(`Error: Batch "${batchCode}" not found`);
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
const summary = getBatchSummary(db, batch.id);
|
|
426
|
+
if (!summary) {
|
|
427
|
+
console.error(`Error: Could not load summary for batch "${batchCode}"`);
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
printDetailedReport(db, summary, limitErrors);
|
|
431
|
+
} else if (flags.latest) {
|
|
432
|
+
// Report for latest batch
|
|
433
|
+
const batch = getLatestBatch(db);
|
|
434
|
+
if (!batch) {
|
|
435
|
+
console.error('Error: No batches found in database');
|
|
436
|
+
process.exit(1);
|
|
437
|
+
}
|
|
438
|
+
const summary = getBatchSummary(db, batch.id);
|
|
439
|
+
if (!summary) {
|
|
440
|
+
console.error('Error: Could not load summary for latest batch');
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
443
|
+
console.log(`Latest batch: ${batch.code}`);
|
|
444
|
+
console.log();
|
|
445
|
+
printDetailedReport(db, summary, limitErrors);
|
|
446
|
+
} else {
|
|
447
|
+
// Summary of all batches
|
|
448
|
+
printAllBatchesSummary(db);
|
|
449
|
+
}
|
|
450
|
+
} finally {
|
|
451
|
+
db.close();
|
|
452
|
+
}
|
|
453
|
+
}
|