@lovenyberg/ove 0.7.0 → 0.9.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/src/queue.test.ts CHANGED
@@ -110,4 +110,178 @@ describe("TaskQueue", () => {
110
110
  const task = queue.dequeue();
111
111
  expect(task!.taskType).toBe("discuss");
112
112
  });
113
+
114
+ it("default priority is 0", () => {
115
+ const id = queue.enqueue({
116
+ userId: "slack:U123",
117
+ repo: "my-app",
118
+ prompt: "normal task",
119
+ });
120
+ const task = queue.get(id);
121
+ expect(task!.priority).toBe(0);
122
+ });
123
+
124
+ it("stores and retrieves priority", () => {
125
+ const id = queue.enqueue({
126
+ userId: "slack:U123",
127
+ repo: "my-app",
128
+ prompt: "urgent task",
129
+ priority: 2,
130
+ });
131
+ const task = queue.get(id);
132
+ expect(task!.priority).toBe(2);
133
+ });
134
+
135
+ it("preserves priority through dequeue", () => {
136
+ queue.enqueue({
137
+ userId: "slack:U123",
138
+ repo: "my-app",
139
+ prompt: "high priority task",
140
+ priority: 1,
141
+ });
142
+ const task = queue.dequeue();
143
+ expect(task!.priority).toBe(1);
144
+ });
145
+
146
+ it("dequeues higher priority tasks before lower priority", () => {
147
+ // Enqueue low priority first, then high priority
148
+ queue.enqueue({
149
+ userId: "slack:U123",
150
+ repo: "repo-a",
151
+ prompt: "normal task",
152
+ priority: 0,
153
+ });
154
+ queue.enqueue({
155
+ userId: "slack:U123",
156
+ repo: "repo-b",
157
+ prompt: "urgent task",
158
+ priority: 2,
159
+ });
160
+ queue.enqueue({
161
+ userId: "slack:U123",
162
+ repo: "repo-c",
163
+ prompt: "high task",
164
+ priority: 1,
165
+ });
166
+
167
+ // Should dequeue urgent (2) first, then high (1), then normal (0)
168
+ const first = queue.dequeue();
169
+ expect(first!.prompt).toBe("urgent task");
170
+ expect(first!.priority).toBe(2);
171
+
172
+ const second = queue.dequeue();
173
+ expect(second!.prompt).toBe("high task");
174
+ expect(second!.priority).toBe(1);
175
+
176
+ const third = queue.dequeue();
177
+ expect(third!.prompt).toBe("normal task");
178
+ expect(third!.priority).toBe(0);
179
+ });
180
+
181
+ it("dequeues by FIFO within same priority", () => {
182
+ queue.enqueue({
183
+ userId: "slack:U123",
184
+ repo: "repo-a",
185
+ prompt: "first normal",
186
+ priority: 0,
187
+ });
188
+ queue.enqueue({
189
+ userId: "slack:U123",
190
+ repo: "repo-b",
191
+ prompt: "second normal",
192
+ priority: 0,
193
+ });
194
+
195
+ const first = queue.dequeue();
196
+ expect(first!.prompt).toBe("first normal");
197
+
198
+ const second = queue.dequeue();
199
+ expect(second!.prompt).toBe("second normal");
200
+ });
201
+
202
+ describe("metrics()", () => {
203
+ it("returns zeroes on empty queue", () => {
204
+ const m = queue.metrics();
205
+ expect(m.counts).toEqual({ pending: 0, running: 0, completed: 0, failed: 0 });
206
+ expect(m.avgDurationByRepo).toEqual([]);
207
+ expect(m.throughput.lastHour).toBe(0);
208
+ expect(m.throughput.last24h).toBe(0);
209
+ expect(m.errorRate).toBe(0);
210
+ expect(m.repoBreakdown).toEqual([]);
211
+ });
212
+
213
+ it("returns correct counts by status", () => {
214
+ queue.enqueue({ userId: "u1", repo: "a", prompt: "p1" });
215
+ const id2 = queue.enqueue({ userId: "u1", repo: "b", prompt: "p2" });
216
+ queue.dequeue(); // dequeues repo "a" -> running
217
+ // repo "b" is still pending since "a" is running but different repo, so dequeue "b"
218
+ const task2 = queue.dequeue();
219
+ if (task2) queue.complete(task2.id, "done");
220
+
221
+ const m = queue.metrics();
222
+ expect(m.counts.running).toBe(1);
223
+ expect(m.counts.completed).toBe(1);
224
+ });
225
+
226
+ it("computes average duration by repo", () => {
227
+ const id1 = queue.enqueue({ userId: "u1", repo: "app-a", prompt: "p1" });
228
+ queue.dequeue();
229
+ queue.complete(id1, "ok");
230
+
231
+ const id2 = queue.enqueue({ userId: "u1", repo: "app-a", prompt: "p2" });
232
+ queue.dequeue();
233
+ queue.complete(id2, "ok");
234
+
235
+ const m = queue.metrics();
236
+ expect(m.avgDurationByRepo.length).toBe(1);
237
+ expect(m.avgDurationByRepo[0].repo).toBe("app-a");
238
+ expect(m.avgDurationByRepo[0].count).toBe(2);
239
+ expect(m.avgDurationByRepo[0].avgMs).toBeGreaterThanOrEqual(0);
240
+ });
241
+
242
+ it("computes throughput for recent tasks", () => {
243
+ const id = queue.enqueue({ userId: "u1", repo: "x", prompt: "p" });
244
+ queue.dequeue();
245
+ queue.complete(id, "done");
246
+
247
+ const m = queue.metrics();
248
+ expect(m.throughput.lastHour).toBe(1);
249
+ expect(m.throughput.last24h).toBe(1);
250
+ });
251
+
252
+ it("computes error rate", () => {
253
+ const id1 = queue.enqueue({ userId: "u1", repo: "a", prompt: "p1" });
254
+ queue.dequeue();
255
+ queue.complete(id1, "ok");
256
+
257
+ const id2 = queue.enqueue({ userId: "u1", repo: "a", prompt: "p2" });
258
+ queue.dequeue();
259
+ queue.fail(id2, "broken");
260
+
261
+ const m = queue.metrics();
262
+ // 1 failed out of 2 finished = 0.5
263
+ expect(m.errorRate).toBe(0.5);
264
+ });
265
+
266
+ it("returns per-repo breakdown", () => {
267
+ queue.enqueue({ userId: "u1", repo: "alpha", prompt: "p1" });
268
+ queue.enqueue({ userId: "u1", repo: "beta", prompt: "p2" });
269
+ queue.enqueue({ userId: "u2", repo: "alpha", prompt: "p3" });
270
+
271
+ const m = queue.metrics();
272
+ expect(m.repoBreakdown.length).toBe(2);
273
+ const alpha = m.repoBreakdown.find((r) => r.repo === "alpha");
274
+ expect(alpha).toBeDefined();
275
+ expect(alpha!.pending).toBe(2);
276
+ const beta = m.repoBreakdown.find((r) => r.repo === "beta");
277
+ expect(beta).toBeDefined();
278
+ expect(beta!.pending).toBe(1);
279
+ });
280
+
281
+ it("error rate is zero when no finished tasks", () => {
282
+ queue.enqueue({ userId: "u1", repo: "x", prompt: "p" });
283
+ const m = queue.metrics();
284
+ expect(m.errorRate).toBe(0);
285
+ });
286
+ });
113
287
  });
package/src/queue.ts CHANGED
@@ -5,6 +5,7 @@ export interface TaskInput {
5
5
  repo: string;
6
6
  prompt: string;
7
7
  taskType?: string;
8
+ priority?: number;
8
9
  }
9
10
 
10
11
  export interface Task {
@@ -15,6 +16,7 @@ export interface Task {
15
16
  status: "pending" | "running" | "completed" | "failed";
16
17
  result: string | null;
17
18
  taskType: string | null;
19
+ priority: number;
18
20
  createdAt: string;
19
21
  completedAt: string | null;
20
22
  }
@@ -27,6 +29,7 @@ interface TaskRow {
27
29
  status: string;
28
30
  result: string | null;
29
31
  task_type: string | null;
32
+ priority: number;
30
33
  created_at: string;
31
34
  completed_at: string | null;
32
35
  }
@@ -45,6 +48,7 @@ export class TaskQueue {
45
48
  status TEXT NOT NULL DEFAULT 'pending',
46
49
  result TEXT,
47
50
  task_type TEXT,
51
+ priority INTEGER NOT NULL DEFAULT 0,
48
52
  created_at TEXT NOT NULL,
49
53
  completed_at TEXT
50
54
  )
@@ -54,14 +58,18 @@ export class TaskQueue {
54
58
  if (!columns.some(c => c.name === "task_type")) {
55
59
  this.db.run("ALTER TABLE tasks ADD COLUMN task_type TEXT");
56
60
  }
61
+ // Migration: add priority column if missing (backward compat)
62
+ if (!columns.some(c => c.name === "priority")) {
63
+ this.db.run("ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0");
64
+ }
57
65
  }
58
66
 
59
67
  enqueue(input: TaskInput): string {
60
68
  const id = crypto.randomUUID();
61
69
  this.db.run(
62
- `INSERT INTO tasks (id, user_id, repo, prompt, status, task_type, created_at)
63
- VALUES (?, ?, ?, ?, 'pending', ?, ?)`,
64
- [id, input.userId, input.repo, input.prompt, input.taskType || null, new Date().toISOString()]
70
+ `INSERT INTO tasks (id, user_id, repo, prompt, status, task_type, priority, created_at)
71
+ VALUES (?, ?, ?, ?, 'pending', ?, ?, ?)`,
72
+ [id, input.userId, input.repo, input.prompt, input.taskType || null, input.priority ?? 0, new Date().toISOString()]
65
73
  );
66
74
  return id;
67
75
  }
@@ -72,7 +80,7 @@ export class TaskQueue {
72
80
  `SELECT * FROM tasks
73
81
  WHERE status = 'pending'
74
82
  AND repo NOT IN (SELECT repo FROM tasks WHERE status = 'running')
75
- ORDER BY created_at ASC
83
+ ORDER BY priority DESC, created_at ASC
76
84
  LIMIT 1`
77
85
  )
78
86
  .get() as TaskRow;
@@ -106,7 +114,7 @@ export class TaskQueue {
106
114
  listByUser(userId: string, limit: number = 10): Task[] {
107
115
  const rows = this.db
108
116
  .query(
109
- `SELECT * FROM tasks WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`
117
+ `SELECT * FROM tasks WHERE user_id = ? ORDER BY priority DESC, created_at DESC LIMIT ?`
110
118
  )
111
119
  .all(userId, limit) as TaskRow[];
112
120
  return rows.map((r) => this.rowToTask(r));
@@ -126,10 +134,79 @@ export class TaskQueue {
126
134
  return row;
127
135
  }
128
136
 
137
+ metrics(): {
138
+ counts: { pending: number; running: number; completed: number; failed: number };
139
+ avgDurationByRepo: { repo: string; avgMs: number; count: number }[];
140
+ throughput: { lastHour: number; last24h: number };
141
+ errorRate: number;
142
+ repoBreakdown: { repo: string; pending: number; running: number; completed: number; failed: number }[];
143
+ } {
144
+ const counts = this.stats();
145
+
146
+ // Average task duration by repo (only completed/failed tasks with both timestamps)
147
+ const avgDurationByRepo = this.db
148
+ .query(
149
+ `SELECT repo,
150
+ AVG((julianday(completed_at) - julianday(created_at)) * 86400000) as avg_ms,
151
+ COUNT(*) as count
152
+ FROM tasks
153
+ WHERE completed_at IS NOT NULL AND created_at IS NOT NULL
154
+ AND status IN ('completed', 'failed')
155
+ GROUP BY repo
156
+ ORDER BY count DESC`
157
+ )
158
+ .all() as { repo: string; avg_ms: number; count: number }[];
159
+
160
+ // Task throughput — completed in last hour and last 24h
161
+ const now = new Date().toISOString();
162
+ const throughputRow = this.db
163
+ .query(
164
+ `SELECT
165
+ COUNT(*) FILTER (WHERE completed_at >= datetime(?, '-1 hour')) as last_hour,
166
+ COUNT(*) FILTER (WHERE completed_at >= datetime(?, '-24 hours')) as last_24h
167
+ FROM tasks
168
+ WHERE status IN ('completed', 'failed') AND completed_at IS NOT NULL`
169
+ )
170
+ .get(now, now) as { last_hour: number; last_24h: number };
171
+
172
+ // Error rate: failed / total finished
173
+ const total = counts.completed + counts.failed;
174
+ const errorRate = total > 0 ? counts.failed / total : 0;
175
+
176
+ // Per-repo breakdown
177
+ const repoBreakdown = this.db
178
+ .query(
179
+ `SELECT repo,
180
+ COUNT(*) FILTER (WHERE status = 'pending') as pending,
181
+ COUNT(*) FILTER (WHERE status = 'running') as running,
182
+ COUNT(*) FILTER (WHERE status = 'completed') as completed,
183
+ COUNT(*) FILTER (WHERE status = 'failed') as failed
184
+ FROM tasks
185
+ GROUP BY repo
186
+ ORDER BY (COUNT(*) FILTER (WHERE status = 'running') + COUNT(*) FILTER (WHERE status = 'pending')) DESC, repo ASC`
187
+ )
188
+ .all() as { repo: string; pending: number; running: number; completed: number; failed: number }[];
189
+
190
+ return {
191
+ counts,
192
+ avgDurationByRepo: avgDurationByRepo.map((r) => ({
193
+ repo: r.repo,
194
+ avgMs: Math.round(r.avg_ms),
195
+ count: r.count,
196
+ })),
197
+ throughput: {
198
+ lastHour: throughputRow.last_hour,
199
+ last24h: throughputRow.last_24h,
200
+ },
201
+ errorRate: Math.round(errorRate * 10000) / 10000, // 4 decimal places
202
+ repoBreakdown,
203
+ };
204
+ }
205
+
129
206
  listActive(limit: number = 20): Task[] {
130
207
  const rows = this.db
131
208
  .query(
132
- `SELECT * FROM tasks WHERE status IN ('running', 'pending') ORDER BY created_at ASC LIMIT ?`
209
+ `SELECT * FROM tasks WHERE status IN ('running', 'pending') ORDER BY priority DESC, created_at ASC LIMIT ?`
133
210
  )
134
211
  .all(limit) as TaskRow[];
135
212
  return rows.map((r) => this.rowToTask(r));
@@ -159,7 +236,7 @@ export class TaskQueue {
159
236
  sql += ` WHERE status = ?`;
160
237
  params.push(status);
161
238
  }
162
- sql += ` ORDER BY created_at DESC LIMIT ?`;
239
+ sql += ` ORDER BY priority DESC, created_at DESC LIMIT ?`;
163
240
  params.push(limit);
164
241
  const rows = this.db.query(sql).all(...params) as TaskRow[];
165
242
  return rows.map((r) => this.rowToTask(r));
@@ -182,6 +259,7 @@ export class TaskQueue {
182
259
  status: row.status as "pending" | "running" | "completed" | "failed",
183
260
  result: row.result,
184
261
  taskType: row.task_type || null,
262
+ priority: row.priority ?? 0,
185
263
  createdAt: row.created_at,
186
264
  completedAt: row.completed_at,
187
265
  };
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "bun:test";
2
- import { parseMessage, buildPrompt, buildCronPrompt } from "./router";
2
+ import { parseMessage, buildPrompt, buildCronPrompt, parsePriority } from "./router";
3
3
 
4
4
  describe("parseMessage", () => {
5
5
  it("parses PR review command", () => {
@@ -260,6 +260,73 @@ describe("parseMessage", () => {
260
260
  });
261
261
  });
262
262
 
263
+ describe("set-mode parsing", () => {
264
+ it("parses 'mode assistant'", () => {
265
+ const result = parseMessage("mode assistant");
266
+ expect(result.type).toBe("set-mode");
267
+ expect(result.args.mode).toBe("assistant");
268
+ });
269
+
270
+ it("parses 'mode strict'", () => {
271
+ const result = parseMessage("mode strict");
272
+ expect(result.type).toBe("set-mode");
273
+ expect(result.args.mode).toBe("strict");
274
+ });
275
+
276
+ it("parses '/mode assistant'", () => {
277
+ const result = parseMessage("/mode assistant");
278
+ expect(result.type).toBe("set-mode");
279
+ expect(result.args.mode).toBe("assistant");
280
+ });
281
+
282
+ it("parses 'assistant mode'", () => {
283
+ const result = parseMessage("assistant mode");
284
+ expect(result.type).toBe("set-mode");
285
+ expect(result.args.mode).toBe("assistant");
286
+ });
287
+
288
+ it("parses 'yolo mode'", () => {
289
+ const result = parseMessage("yolo mode");
290
+ expect(result.type).toBe("set-mode");
291
+ expect(result.args.mode).toBe("assistant");
292
+ });
293
+
294
+ it("parses 'be more helpful'", () => {
295
+ const result = parseMessage("be more helpful");
296
+ expect(result.type).toBe("set-mode");
297
+ expect(result.args.mode).toBe("assistant");
298
+ });
299
+
300
+ it("parses 'help me with anything'", () => {
301
+ const result = parseMessage("help me with anything");
302
+ expect(result.type).toBe("set-mode");
303
+ expect(result.args.mode).toBe("assistant");
304
+ });
305
+
306
+ it("parses 'strict mode'", () => {
307
+ const result = parseMessage("strict mode");
308
+ expect(result.type).toBe("set-mode");
309
+ expect(result.args.mode).toBe("strict");
310
+ });
311
+
312
+ it("parses 'code mode'", () => {
313
+ const result = parseMessage("code mode");
314
+ expect(result.type).toBe("set-mode");
315
+ expect(result.args.mode).toBe("strict");
316
+ });
317
+
318
+ it("parses 'back to normal'", () => {
319
+ const result = parseMessage("back to normal");
320
+ expect(result.type).toBe("set-mode");
321
+ expect(result.args.mode).toBe("strict");
322
+ });
323
+
324
+ it("does NOT match 'help me fix a bug on my-app' as set-mode", () => {
325
+ const result = parseMessage("help me fix a bug on my-app");
326
+ expect(result.type).not.toBe("set-mode");
327
+ });
328
+ });
329
+
263
330
  describe("buildPrompt", () => {
264
331
  it("builds review-pr prompt", () => {
265
332
  const prompt = buildPrompt({ type: "review-pr", repo: "my-app", args: { prNumber: 42 }, rawText: "" });
@@ -314,3 +381,86 @@ describe("buildCronPrompt", () => {
314
381
  expect(prompt).toContain(original);
315
382
  });
316
383
  });
384
+
385
+ describe("parsePriority", () => {
386
+ it("returns 0 for normal text", () => {
387
+ const result = parsePriority("fix the login bug");
388
+ expect(result.priority).toBe(0);
389
+ expect(result.text).toBe("fix the login bug");
390
+ });
391
+
392
+ it("parses 'urgent:' prefix as priority 2", () => {
393
+ const result = parsePriority("urgent: fix the login bug");
394
+ expect(result.priority).toBe(2);
395
+ expect(result.text).toBe("fix the login bug");
396
+ });
397
+
398
+ it("parses '!important' marker as priority 1", () => {
399
+ const result = parsePriority("fix the login bug !important");
400
+ expect(result.priority).toBe(1);
401
+ expect(result.text).toBe("fix the login bug");
402
+ });
403
+
404
+ it("parses 'p1' as priority 1 (high)", () => {
405
+ const result = parsePriority("fix the login bug p1");
406
+ expect(result.priority).toBe(1);
407
+ expect(result.text).toBe("fix the login bug");
408
+ });
409
+
410
+ it("parses 'p0' as priority 2 (urgent)", () => {
411
+ const result = parsePriority("p0 fix the login bug");
412
+ expect(result.priority).toBe(2);
413
+ expect(result.text).toBe("fix the login bug");
414
+ });
415
+
416
+ it("parses 'p2' as priority 0 (normal)", () => {
417
+ const result = parsePriority("fix the login bug p2");
418
+ expect(result.priority).toBe(0);
419
+ });
420
+
421
+ it("parses '--priority high' as priority 1", () => {
422
+ const result = parsePriority("fix the login bug --priority high");
423
+ expect(result.priority).toBe(1);
424
+ expect(result.text).toBe("fix the login bug");
425
+ });
426
+
427
+ it("parses '--priority urgent' as priority 2", () => {
428
+ const result = parsePriority("fix the login bug --priority urgent");
429
+ expect(result.priority).toBe(2);
430
+ expect(result.text).toBe("fix the login bug");
431
+ });
432
+
433
+ it("parses '--priority normal' as priority 0", () => {
434
+ const result = parsePriority("fix the login bug --priority normal");
435
+ expect(result.priority).toBe(0);
436
+ expect(result.text).toBe("fix the login bug");
437
+ });
438
+ });
439
+
440
+ describe("parseMessage priority integration", () => {
441
+ it("default priority is 0", () => {
442
+ const result = parseMessage("fix issue #15 on infra");
443
+ expect(result.priority).toBe(0);
444
+ });
445
+
446
+ it("detects urgent: prefix and sets priority 2", () => {
447
+ const result = parseMessage("urgent: fix the login bug on my-app");
448
+ expect(result.priority).toBe(2);
449
+ expect(result.type).toBe("free-form");
450
+ });
451
+
452
+ it("detects !important and sets priority 1", () => {
453
+ const result = parseMessage("fix issue #15 on infra !important");
454
+ expect(result.priority).toBe(1);
455
+ });
456
+
457
+ it("detects p1 and sets priority 1", () => {
458
+ const result = parseMessage("review PR #42 on my-app p1");
459
+ expect(result.priority).toBe(1);
460
+ });
461
+
462
+ it("detects --priority urgent and sets priority 2", () => {
463
+ const result = parseMessage("fix the login bug on my-app --priority urgent");
464
+ expect(result.priority).toBe(2);
465
+ });
466
+ });