@telepat/snoopy 0.1.10 → 0.1.11
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 +3 -0
- package/dist/src/services/db/repositories/runsRepo.d.ts +4 -0
- package/dist/src/services/db/repositories/runsRepo.js +27 -10
- package/dist/src/services/db/repositories/scanItemsRepo.d.ts +7 -0
- package/dist/src/services/db/repositories/scanItemsRepo.js +53 -5
- package/dist/src/services/db/sqlite.js +1 -0
- package/dist/src/services/scheduler/jobRunner.d.ts +1 -1
- package/dist/src/services/scheduler/jobRunner.js +77 -39
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -126,6 +126,9 @@ snoopy job run --limit 5
|
|
|
126
126
|
snoopy job run <jobRef> --limit 5
|
|
127
127
|
```
|
|
128
128
|
|
|
129
|
+
If another run is already active for the same job, Snoopy marks the new attempt as `skipped` with an `already active` message.
|
|
130
|
+
Duplicate candidates matching an existing scan item are treated as already scanned and do not fail the run.
|
|
131
|
+
|
|
129
132
|
If `<jobRef>` is omitted for `job run`, `job enable`, `job disable`, `job delete`, `start`, `stop`, `errors`, or `results`, Snoopy shows your job list and lets you pick with up/down arrows and Enter.
|
|
130
133
|
|
|
131
134
|
4. View run history:
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { getDb } from '../sqlite.js';
|
|
3
|
+
export class ActiveRunConflictError extends Error {
|
|
4
|
+
jobId;
|
|
5
|
+
constructor(jobId) {
|
|
6
|
+
super(`A run is already active for job ${jobId}.`);
|
|
7
|
+
this.name = 'ActiveRunConflictError';
|
|
8
|
+
this.jobId = jobId;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
3
11
|
export class RunsRepository {
|
|
4
12
|
db = getDb();
|
|
5
13
|
runSelectWithJob = `SELECT
|
|
@@ -41,16 +49,25 @@ export class RunsRepository {
|
|
|
41
49
|
}
|
|
42
50
|
startRun(jobId, logFilePath) {
|
|
43
51
|
const id = crypto.randomUUID();
|
|
44
|
-
|
|
45
|
-
.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
try {
|
|
53
|
+
this.db
|
|
54
|
+
.prepare(`INSERT INTO job_runs (
|
|
55
|
+
id,
|
|
56
|
+
job_id,
|
|
57
|
+
status,
|
|
58
|
+
started_at,
|
|
59
|
+
created_at,
|
|
60
|
+
log_file_path
|
|
61
|
+
) VALUES (?, ?, 'running', datetime('now'), datetime('now'), ?)`)
|
|
62
|
+
.run(id, jobId, logFilePath ?? null);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
66
|
+
if (message.includes('idx_job_runs_active_job') || message.includes('UNIQUE constraint failed: job_runs.job_id')) {
|
|
67
|
+
throw new ActiveRunConflictError(jobId);
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
54
71
|
return id;
|
|
55
72
|
}
|
|
56
73
|
completeRun(runId, stats) {
|
|
@@ -87,12 +87,18 @@ export interface AnalyticsByJobRow extends AnalyticsTotalsRow {
|
|
|
87
87
|
jobName: string;
|
|
88
88
|
jobSlug: string;
|
|
89
89
|
}
|
|
90
|
+
export interface CreateScanItemResult {
|
|
91
|
+
id: string;
|
|
92
|
+
inserted: boolean;
|
|
93
|
+
}
|
|
90
94
|
interface AnalyticsFilter {
|
|
91
95
|
jobId?: string;
|
|
92
96
|
days: number;
|
|
93
97
|
}
|
|
94
98
|
export declare class ScanItemsRepository {
|
|
95
99
|
private readonly db;
|
|
100
|
+
private isDedupConflict;
|
|
101
|
+
private findExistingScanItemId;
|
|
96
102
|
private mapScanItemRows;
|
|
97
103
|
private buildFilterClause;
|
|
98
104
|
private toAnalyticsTotalsRow;
|
|
@@ -107,6 +113,7 @@ export declare class ScanItemsRepository {
|
|
|
107
113
|
listAnalyticsBySubreddit(filter: AnalyticsFilter): AnalyticsBySubredditRow[];
|
|
108
114
|
listAnalyticsByJob(days: number): AnalyticsByJobRow[];
|
|
109
115
|
existsComment(jobId: string, postId: string, commentId: string): boolean;
|
|
116
|
+
createWithStatus(item: NewScanItem): CreateScanItemResult;
|
|
110
117
|
create(item: NewScanItem): string;
|
|
111
118
|
listCommentThreadNodes(scanItemId: string): CommentThreadNodeRow[];
|
|
112
119
|
}
|
|
@@ -2,6 +2,29 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import { getDb } from '../sqlite.js';
|
|
3
3
|
export class ScanItemsRepository {
|
|
4
4
|
db = getDb();
|
|
5
|
+
isDedupConflict(error) {
|
|
6
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
7
|
+
return message.includes('idx_scan_items_dedup');
|
|
8
|
+
}
|
|
9
|
+
findExistingScanItemId(jobId, postId, commentId) {
|
|
10
|
+
const query = commentId === null
|
|
11
|
+
? `SELECT id
|
|
12
|
+
FROM scan_items
|
|
13
|
+
WHERE job_id = ?
|
|
14
|
+
AND reddit_post_id = ?
|
|
15
|
+
AND reddit_comment_id IS NULL
|
|
16
|
+
LIMIT 1`
|
|
17
|
+
: `SELECT id
|
|
18
|
+
FROM scan_items
|
|
19
|
+
WHERE job_id = ?
|
|
20
|
+
AND reddit_post_id = ?
|
|
21
|
+
AND reddit_comment_id = ?
|
|
22
|
+
LIMIT 1`;
|
|
23
|
+
const row = commentId === null
|
|
24
|
+
? this.db.prepare(query).get(jobId, postId)
|
|
25
|
+
: this.db.prepare(query).get(jobId, postId, commentId);
|
|
26
|
+
return row?.id ?? null;
|
|
27
|
+
}
|
|
5
28
|
mapScanItemRows(rows) {
|
|
6
29
|
return rows.map((row) => ({
|
|
7
30
|
...row,
|
|
@@ -260,11 +283,11 @@ export class ScanItemsRepository {
|
|
|
260
283
|
.get(jobId, postId, commentId);
|
|
261
284
|
return Boolean(row);
|
|
262
285
|
}
|
|
263
|
-
|
|
286
|
+
createWithStatus(item) {
|
|
264
287
|
const id = crypto.randomUUID();
|
|
265
288
|
const createInTransaction = this.db.transaction((newId, newItem) => {
|
|
266
|
-
this.db
|
|
267
|
-
.prepare(`INSERT INTO scan_items (
|
|
289
|
+
const insertResult = this.db
|
|
290
|
+
.prepare(`INSERT OR IGNORE INTO scan_items (
|
|
268
291
|
id,
|
|
269
292
|
job_id,
|
|
270
293
|
run_id,
|
|
@@ -288,6 +311,13 @@ export class ScanItemsRepository {
|
|
|
288
311
|
created_at
|
|
289
312
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`)
|
|
290
313
|
.run(newId, newItem.jobId, newItem.runId, newItem.type, newItem.redditPostId, newItem.redditCommentId, newItem.subreddit, newItem.author, newItem.title, newItem.body, newItem.url, newItem.redditPostedAt, newItem.qualified ? 1 : 0, newItem.viewed ? 1 : 0, newItem.validated ? 1 : 0, newItem.processed ? 1 : 0, newItem.promptTokens ?? 0, newItem.completionTokens ?? 0, newItem.estimatedCostUsd ?? null, newItem.qualificationReason);
|
|
314
|
+
if (insertResult.changes === 0) {
|
|
315
|
+
const existingId = this.findExistingScanItemId(newItem.jobId, newItem.redditPostId, newItem.redditCommentId);
|
|
316
|
+
return {
|
|
317
|
+
id: existingId ?? newId,
|
|
318
|
+
inserted: false
|
|
319
|
+
};
|
|
320
|
+
}
|
|
291
321
|
for (const node of newItem.commentThreadNodes ?? []) {
|
|
292
322
|
this.db
|
|
293
323
|
.prepare(`INSERT INTO comment_thread_nodes (
|
|
@@ -303,9 +333,27 @@ export class ScanItemsRepository {
|
|
|
303
333
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`)
|
|
304
334
|
.run(crypto.randomUUID(), newId, node.redditCommentId, node.parentRedditCommentId, node.author, node.body, node.depth, node.isTarget ? 1 : 0);
|
|
305
335
|
}
|
|
336
|
+
return {
|
|
337
|
+
id: newId,
|
|
338
|
+
inserted: true
|
|
339
|
+
};
|
|
306
340
|
});
|
|
307
|
-
|
|
308
|
-
|
|
341
|
+
try {
|
|
342
|
+
return createInTransaction(id, item);
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
if (!this.isDedupConflict(error)) {
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
const existingId = this.findExistingScanItemId(item.jobId, item.redditPostId, item.redditCommentId);
|
|
349
|
+
return {
|
|
350
|
+
id: existingId ?? id,
|
|
351
|
+
inserted: false
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
create(item) {
|
|
356
|
+
return this.createWithStatus(item).id;
|
|
309
357
|
}
|
|
310
358
|
listCommentThreadNodes(scanItemId) {
|
|
311
359
|
const rows = this.db
|
|
@@ -109,6 +109,7 @@ export function getDb() {
|
|
|
109
109
|
// Column already exists.
|
|
110
110
|
}
|
|
111
111
|
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_slug ON jobs(slug)');
|
|
112
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_job_runs_active_job ON job_runs(job_id) WHERE status = 'running'");
|
|
112
113
|
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_scan_items_dedup ON scan_items(job_id, reddit_post_id, COALESCE(reddit_comment_id, ''))");
|
|
113
114
|
db.exec('CREATE INDEX IF NOT EXISTS idx_scan_items_job_qualified_posted ON scan_items(job_id, qualified, reddit_posted_at DESC, created_at DESC)');
|
|
114
115
|
db.exec('CREATE INDEX IF NOT EXISTS idx_scan_items_job_created ON scan_items(job_id, created_at DESC)');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SettingsRepository } from '../db/repositories/settingsRepo.js';
|
|
2
|
-
import { RunsRepository } from '../db/repositories/runsRepo.js';
|
|
2
|
+
import { ActiveRunConflictError, RunsRepository } from '../db/repositories/runsRepo.js';
|
|
3
3
|
import { ScanItemsRepository } from '../db/repositories/scanItemsRepo.js';
|
|
4
4
|
import { getOpenRouterApiKey } from '../security/secretStore.js';
|
|
5
5
|
import { logger } from '../../utils/logger.js';
|
|
@@ -86,7 +86,20 @@ export class JobRunner {
|
|
|
86
86
|
const appSettings = this.settingsRepo.getAppSettings();
|
|
87
87
|
const model = appSettings.model;
|
|
88
88
|
const modelSettings = appSettings.modelSettings;
|
|
89
|
-
|
|
89
|
+
let runId;
|
|
90
|
+
try {
|
|
91
|
+
runId = this.runsRepo.startRun(job.id);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error instanceof ActiveRunConflictError) {
|
|
95
|
+
const message = `Skipped job ${job.name} (${job.id}): another run is already active.`;
|
|
96
|
+
this.runsRepo.addRun(job.id, 'skipped', message);
|
|
97
|
+
this.emit(options, { type: 'run_skipped', reason: 'already_running', message });
|
|
98
|
+
logger.warn(message);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
90
103
|
const runLogger = createRunLogger(runId);
|
|
91
104
|
this.runsRepo.setLogFilePath(runId, runLogger.getLogFilePath());
|
|
92
105
|
const redditTraceHooks = {
|
|
@@ -174,7 +187,7 @@ export class JobRunner {
|
|
|
174
187
|
postBody: post.body
|
|
175
188
|
});
|
|
176
189
|
runLogger.info(JSON.stringify({ event: 'post_qualify_result', postId: post.id, result }, null, 2));
|
|
177
|
-
this.scanItemsRepo.
|
|
190
|
+
const postInsert = this.scanItemsRepo.createWithStatus({
|
|
178
191
|
jobId: job.id,
|
|
179
192
|
runId,
|
|
180
193
|
type: 'post',
|
|
@@ -192,24 +205,36 @@ export class JobRunner {
|
|
|
192
205
|
estimatedCostUsd: this.estimateCost(result.promptTokens, result.completionTokens),
|
|
193
206
|
qualificationReason: result.reason
|
|
194
207
|
});
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
208
|
+
if (!postInsert.inserted) {
|
|
209
|
+
this.emit(options, {
|
|
210
|
+
type: 'post_scanned',
|
|
211
|
+
postId: post.id,
|
|
212
|
+
subreddit: post.subreddit,
|
|
213
|
+
status: 'existing',
|
|
214
|
+
itemsNew: runStats.itemsNew,
|
|
215
|
+
itemsQualified: runStats.itemsQualified
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
runStats.itemsNew += 1;
|
|
220
|
+
if (result.qualified) {
|
|
221
|
+
runStats.itemsQualified += 1;
|
|
222
|
+
}
|
|
223
|
+
this.accumulateTokens(runStats, result);
|
|
224
|
+
this.emit(options, {
|
|
225
|
+
type: 'post_scanned',
|
|
226
|
+
postId: post.id,
|
|
227
|
+
subreddit: post.subreddit,
|
|
228
|
+
status: 'new',
|
|
229
|
+
title: post.title,
|
|
230
|
+
bodySnippet: toSnippet(post.body),
|
|
231
|
+
postUrl: post.url,
|
|
232
|
+
qualified: result.qualified,
|
|
233
|
+
qualificationReason: result.reason,
|
|
234
|
+
itemsNew: runStats.itemsNew,
|
|
235
|
+
itemsQualified: runStats.itemsQualified
|
|
236
|
+
});
|
|
198
237
|
}
|
|
199
|
-
this.accumulateTokens(runStats, result);
|
|
200
|
-
this.emit(options, {
|
|
201
|
-
type: 'post_scanned',
|
|
202
|
-
postId: post.id,
|
|
203
|
-
subreddit: post.subreddit,
|
|
204
|
-
status: 'new',
|
|
205
|
-
title: post.title,
|
|
206
|
-
bodySnippet: toSnippet(post.body),
|
|
207
|
-
postUrl: post.url,
|
|
208
|
-
qualified: result.qualified,
|
|
209
|
-
qualificationReason: result.reason,
|
|
210
|
-
itemsNew: runStats.itemsNew,
|
|
211
|
-
itemsQualified: runStats.itemsQualified
|
|
212
|
-
});
|
|
213
238
|
}
|
|
214
239
|
else {
|
|
215
240
|
this.emit(options, {
|
|
@@ -319,7 +344,7 @@ export class JobRunner {
|
|
|
319
344
|
author,
|
|
320
345
|
result
|
|
321
346
|
}, null, 2));
|
|
322
|
-
this.scanItemsRepo.
|
|
347
|
+
const commentInsert = this.scanItemsRepo.createWithStatus({
|
|
323
348
|
jobId: job.id,
|
|
324
349
|
runId,
|
|
325
350
|
type: 'comment',
|
|
@@ -345,25 +370,38 @@ export class JobRunner {
|
|
|
345
370
|
isTarget: index === thread.length - 1
|
|
346
371
|
}))
|
|
347
372
|
});
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
373
|
+
if (!commentInsert.inserted) {
|
|
374
|
+
this.emit(options, {
|
|
375
|
+
type: 'comment_scanned',
|
|
376
|
+
postId: post.id,
|
|
377
|
+
commentId: lastComment.id,
|
|
378
|
+
author,
|
|
379
|
+
status: 'existing',
|
|
380
|
+
itemsNew: runStats.itemsNew,
|
|
381
|
+
itemsQualified: runStats.itemsQualified
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
runStats.itemsNew += 1;
|
|
386
|
+
if (result.qualified) {
|
|
387
|
+
runStats.itemsQualified += 1;
|
|
388
|
+
}
|
|
389
|
+
this.accumulateTokens(runStats, result);
|
|
390
|
+
this.emit(options, {
|
|
391
|
+
type: 'comment_scanned',
|
|
392
|
+
postId: post.id,
|
|
393
|
+
commentId: lastComment.id,
|
|
394
|
+
author,
|
|
395
|
+
status: 'new',
|
|
396
|
+
commentSnippet: toSnippet(lastComment.body),
|
|
397
|
+
postUrl: post.url,
|
|
398
|
+
commentUrl: buildCommentUrl(post.url, lastComment),
|
|
399
|
+
qualified: result.qualified,
|
|
400
|
+
qualificationReason: result.reason,
|
|
401
|
+
itemsNew: runStats.itemsNew,
|
|
402
|
+
itemsQualified: runStats.itemsQualified
|
|
403
|
+
});
|
|
351
404
|
}
|
|
352
|
-
this.accumulateTokens(runStats, result);
|
|
353
|
-
this.emit(options, {
|
|
354
|
-
type: 'comment_scanned',
|
|
355
|
-
postId: post.id,
|
|
356
|
-
commentId: lastComment.id,
|
|
357
|
-
author,
|
|
358
|
-
status: 'new',
|
|
359
|
-
commentSnippet: toSnippet(lastComment.body),
|
|
360
|
-
postUrl: post.url,
|
|
361
|
-
commentUrl: buildCommentUrl(post.url, lastComment),
|
|
362
|
-
qualified: result.qualified,
|
|
363
|
-
qualificationReason: result.reason,
|
|
364
|
-
itemsNew: runStats.itemsNew,
|
|
365
|
-
itemsQualified: runStats.itemsQualified
|
|
366
|
-
});
|
|
367
405
|
}
|
|
368
406
|
}
|
|
369
407
|
}
|