@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 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,3 +1,7 @@
1
+ export declare class ActiveRunConflictError extends Error {
2
+ readonly jobId: string;
3
+ constructor(jobId: string);
4
+ }
1
5
  export interface RunStats {
2
6
  itemsDiscovered: number;
3
7
  itemsNew: number;
@@ -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
- this.db
45
- .prepare(`INSERT INTO job_runs (
46
- id,
47
- job_id,
48
- status,
49
- started_at,
50
- created_at,
51
- log_file_path
52
- ) VALUES (?, ?, 'running', datetime('now'), datetime('now'), ?)`)
53
- .run(id, jobId, logFilePath ?? null);
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
- create(item) {
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
- createInTransaction(id, item);
308
- return id;
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)');
@@ -11,7 +11,7 @@ export type JobRunProgressEvent = {
11
11
  maxNewItems?: number;
12
12
  } | {
13
13
  type: 'run_skipped';
14
- reason: 'missing_api_key';
14
+ reason: 'missing_api_key' | 'already_running';
15
15
  message: string;
16
16
  } | {
17
17
  type: 'subreddit_fetched';
@@ -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
- const runId = this.runsRepo.startRun(job.id);
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.create({
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
- runStats.itemsNew += 1;
196
- if (result.qualified) {
197
- runStats.itemsQualified += 1;
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.create({
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
- runStats.itemsNew += 1;
349
- if (result.qualified) {
350
- runStats.itemsQualified += 1;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telepat/snoopy",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Snoopy CLI for Reddit conversation monitoring jobs.",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",