@lovenyberg/ove 0.2.2 → 0.3.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.ts CHANGED
@@ -19,6 +19,18 @@ export interface Task {
19
19
  completedAt: string | null;
20
20
  }
21
21
 
22
+ interface TaskRow {
23
+ id: string;
24
+ user_id: string;
25
+ repo: string;
26
+ prompt: string;
27
+ status: string;
28
+ result: string | null;
29
+ task_type: string | null;
30
+ created_at: string;
31
+ completed_at: string | null;
32
+ }
33
+
22
34
  export class TaskQueue {
23
35
  private db: Database;
24
36
 
@@ -38,10 +50,9 @@ export class TaskQueue {
38
50
  )
39
51
  `);
40
52
  // Migration: add task_type column if missing (backward compat)
41
- try {
42
- this.db.run(`ALTER TABLE tasks ADD COLUMN task_type TEXT`);
43
- } catch {
44
- // Column already exists
53
+ const columns = this.db.query("PRAGMA table_info(tasks)").all() as { name: string }[];
54
+ if (!columns.some(c => c.name === "task_type")) {
55
+ this.db.run("ALTER TABLE tasks ADD COLUMN task_type TEXT");
45
56
  }
46
57
  }
47
58
 
@@ -64,7 +75,7 @@ export class TaskQueue {
64
75
  ORDER BY created_at ASC
65
76
  LIMIT 1`
66
77
  )
67
- .get() as any;
78
+ .get() as TaskRow;
68
79
 
69
80
  if (!row) return null;
70
81
 
@@ -88,7 +99,7 @@ export class TaskQueue {
88
99
  }
89
100
 
90
101
  get(id: string): Task | null {
91
- const row = this.db.query(`SELECT * FROM tasks WHERE id = ?`).get(id) as any;
102
+ const row = this.db.query(`SELECT * FROM tasks WHERE id = ?`).get(id) as TaskRow;
92
103
  return row ? this.rowToTask(row) : null;
93
104
  }
94
105
 
@@ -97,7 +108,7 @@ export class TaskQueue {
97
108
  .query(
98
109
  `SELECT * FROM tasks WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`
99
110
  )
100
- .all(userId, limit) as any[];
111
+ .all(userId, limit) as TaskRow[];
101
112
  return rows.map((r) => this.rowToTask(r));
102
113
  }
103
114
 
@@ -111,7 +122,7 @@ export class TaskQueue {
111
122
  COUNT(*) FILTER (WHERE status = 'failed') as failed
112
123
  FROM tasks`
113
124
  )
114
- .get() as any;
125
+ .get() as { pending: number; running: number; completed: number; failed: number };
115
126
  return row;
116
127
  }
117
128
 
@@ -120,7 +131,7 @@ export class TaskQueue {
120
131
  .query(
121
132
  `SELECT * FROM tasks WHERE status IN ('running', 'pending') ORDER BY created_at ASC LIMIT ?`
122
133
  )
123
- .all(limit) as any[];
134
+ .all(limit) as TaskRow[];
124
135
  return rows.map((r) => this.rowToTask(r));
125
136
  }
126
137
 
@@ -140,13 +151,13 @@ export class TaskQueue {
140
151
  return result.changes;
141
152
  }
142
153
 
143
- private rowToTask(row: any): Task {
154
+ private rowToTask(row: TaskRow): Task {
144
155
  return {
145
156
  id: row.id,
146
157
  userId: row.user_id,
147
158
  repo: row.repo,
148
159
  prompt: row.prompt,
149
- status: row.status,
160
+ status: row.status as "pending" | "running" | "completed" | "failed",
150
161
  result: row.result,
151
162
  taskType: row.task_type || null,
152
163
  createdAt: row.created_at,
@@ -11,6 +11,16 @@ export interface RepoRecord {
11
11
  lastSyncedAt: string | null;
12
12
  }
13
13
 
14
+ interface RepoRow {
15
+ name: string;
16
+ url: string;
17
+ owner: string | null;
18
+ default_branch: string;
19
+ source: string;
20
+ excluded: number;
21
+ last_synced_at: string | null;
22
+ }
23
+
14
24
  export interface RepoUpsertInput {
15
25
  name: string;
16
26
  url: string;
@@ -62,17 +72,17 @@ export class RepoRegistry {
62
72
  }
63
73
 
64
74
  getByName(name: string): RepoRecord | null {
65
- const row = this.db.query(`SELECT * FROM repos WHERE name = ?`).get(name) as any;
75
+ const row = this.db.query(`SELECT * FROM repos WHERE name = ?`).get(name) as RepoRow;
66
76
  return row ? this.rowToRecord(row) : null;
67
77
  }
68
78
 
69
79
  getAll(): RepoRecord[] {
70
- const rows = this.db.query(`SELECT * FROM repos WHERE excluded = 0 ORDER BY name`).all() as any[];
80
+ const rows = this.db.query(`SELECT * FROM repos WHERE excluded = 0 ORDER BY name`).all() as RepoRow[];
71
81
  return rows.map(r => this.rowToRecord(r));
72
82
  }
73
83
 
74
84
  getAllNames(): string[] {
75
- const rows = this.db.query(`SELECT name FROM repos WHERE excluded = 0 ORDER BY name`).all() as any[];
85
+ const rows = this.db.query(`SELECT name FROM repos WHERE excluded = 0 ORDER BY name`).all() as { name: string }[];
76
86
  return rows.map(r => r.name);
77
87
  }
78
88
 
@@ -94,7 +104,7 @@ export class RepoRegistry {
94
104
  }
95
105
  }
96
106
 
97
- private rowToRecord(row: any): RepoRecord {
107
+ private rowToRecord(row: RepoRow): RepoRecord {
98
108
  return {
99
109
  name: row.name,
100
110
  url: row.url,
@@ -124,6 +134,13 @@ export function parseGhRepoLine(line: string): GhRepoParsed | null {
124
134
  return { name, owner, fullName };
125
135
  }
126
136
 
137
+ interface GhRepoResponse {
138
+ name: string;
139
+ owner?: { login: string };
140
+ defaultBranchRef?: { name: string };
141
+ isArchived: boolean;
142
+ }
143
+
127
144
  export async function syncGitHub(
128
145
  registry: RepoRegistry,
129
146
  orgs?: string[]
@@ -150,10 +167,11 @@ export async function syncGitHub(
150
167
  continue;
151
168
  }
152
169
 
153
- let repos: any[];
170
+ let repos: GhRepoResponse[];
154
171
  try {
155
172
  repos = JSON.parse(output);
156
173
  } catch {
174
+ // JSON parsing failed — log and skip this org
157
175
  logger.warn("gh repo list returned invalid JSON", { org });
158
176
  continue;
159
177
  }
@@ -88,7 +88,9 @@ export class ClaudeRunner implements AgentRunner {
88
88
  }
89
89
  }
90
90
  }
91
- } catch {}
91
+ } catch {
92
+ // Non-JSON line in stream output — skip
93
+ }
92
94
  }
93
95
  }
94
96
  } finally {
@@ -116,7 +116,9 @@ export class CodexRunner implements AgentRunner {
116
116
  if (event.type === "turn.failed") {
117
117
  errorMessage = event.error?.message || "Turn failed";
118
118
  }
119
- } catch {}
119
+ } catch {
120
+ // Non-JSON line in stream output — skip
121
+ }
120
122
  }
121
123
  }
122
124
  } finally {
package/src/schedules.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  import { Database } from "bun:sqlite";
2
2
 
3
+ interface ScheduleRow {
4
+ id: number;
5
+ user_id: string;
6
+ repo: string;
7
+ prompt: string;
8
+ schedule: string;
9
+ description: string | null;
10
+ created_at: string;
11
+ }
12
+
3
13
  export interface Schedule {
4
14
  id: number;
5
15
  userId: string;
@@ -45,7 +55,7 @@ export class ScheduleStore {
45
55
  }
46
56
 
47
57
  listByUser(userId: string): Schedule[] {
48
- return (this.db.query(`SELECT * FROM schedules WHERE user_id = ? ORDER BY id`).all(userId) as any[])
58
+ return (this.db.query(`SELECT * FROM schedules WHERE user_id = ? ORDER BY id`).all(userId) as ScheduleRow[])
49
59
  .map(this.rowToSchedule);
50
60
  }
51
61
 
@@ -55,11 +65,11 @@ export class ScheduleStore {
55
65
  }
56
66
 
57
67
  getAll(): Schedule[] {
58
- return (this.db.query(`SELECT * FROM schedules ORDER BY id`).all() as any[])
68
+ return (this.db.query(`SELECT * FROM schedules ORDER BY id`).all() as ScheduleRow[])
59
69
  .map(this.rowToSchedule);
60
70
  }
61
71
 
62
- private rowToSchedule(row: any): Schedule {
72
+ private rowToSchedule(row: ScheduleRow): Schedule {
63
73
  return {
64
74
  id: row.id,
65
75
  userId: row.user_id,
package/src/sessions.ts CHANGED
@@ -6,6 +6,12 @@ export interface ChatMessage {
6
6
  timestamp: string;
7
7
  }
8
8
 
9
+ interface ChatMessageRow {
10
+ role: string;
11
+ content: string;
12
+ created_at: string;
13
+ }
14
+
9
15
  export class SessionStore {
10
16
  private db: Database;
11
17
 
@@ -38,12 +44,12 @@ export class SessionStore {
38
44
  ORDER BY id DESC
39
45
  LIMIT ?`
40
46
  )
41
- .all(userId, limit) as any[];
47
+ .all(userId, limit) as ChatMessageRow[];
42
48
 
43
49
  return rows
44
50
  .reverse()
45
51
  .map((r) => ({
46
- role: r.role,
52
+ role: r.role as "user" | "assistant",
47
53
  content: r.content,
48
54
  timestamp: r.created_at,
49
55
  }));
package/src/worker.ts ADDED
@@ -0,0 +1,173 @@
1
+ import { join } from "node:path";
2
+ import { tmpdir } from "node:os";
3
+ import { unlink } from "node:fs/promises";
4
+ import { logger } from "./logger";
5
+ import { splitAndReply } from "./handlers";
6
+ import type { Config } from "./config";
7
+ import type { TaskQueue, Task } from "./queue";
8
+ import type { RepoManager } from "./repos";
9
+ import type { SessionStore } from "./sessions";
10
+ import type { IncomingMessage, EventAdapter, IncomingEvent } from "./adapters/types";
11
+ import type { AgentRunner, RunOptions, StatusEvent } from "./runner";
12
+
13
+ export interface WorkerDeps {
14
+ config: Config;
15
+ queue: TaskQueue;
16
+ repos: RepoManager;
17
+ sessions: SessionStore;
18
+ pendingReplies: Map<string, IncomingMessage>;
19
+ pendingEventReplies: Map<string, { adapter: EventAdapter; event: IncomingEvent }>;
20
+ runningProcesses: Map<string, { abort: AbortController; task: Task }>;
21
+ getRunnerForRepo: (repo: string) => AgentRunner;
22
+ getRunnerOptsForRepo: (repo: string, baseOpts: RunOptions) => RunOptions;
23
+ getRepoInfo: (repoName: string) => { url: string; defaultBranch: string } | null;
24
+ }
25
+
26
+ async function processTask(task: Task, deps: WorkerDeps) {
27
+ const isCreateProject = task.taskType === "create-project";
28
+ const repoInfo = isCreateProject ? null : deps.getRepoInfo(task.repo);
29
+
30
+ if (!isCreateProject && !repoInfo) {
31
+ deps.queue.fail(task.id, `Unknown repo: ${task.repo}`);
32
+ return;
33
+ }
34
+
35
+ const abortController = new AbortController();
36
+ deps.runningProcesses.set(task.id, { abort: abortController, task });
37
+
38
+ const originalMsg = deps.pendingReplies.get(task.id);
39
+ const statusLog: string[] = [];
40
+
41
+ try {
42
+ await originalMsg?.updateStatus(`Working on it...`);
43
+
44
+ let workDir: string;
45
+
46
+ if (isCreateProject) {
47
+ workDir = join(deps.config.reposDir, task.repo);
48
+ await Bun.write(join(workDir, ".gitkeep"), "");
49
+ } else {
50
+ await deps.repos.cloneIfNeeded(task.repo, repoInfo!.url);
51
+ await deps.repos.pull(task.repo, repoInfo!.defaultBranch);
52
+
53
+ workDir = await deps.repos.createWorktree(
54
+ task.repo,
55
+ task.id,
56
+ repoInfo!.defaultBranch
57
+ );
58
+ }
59
+
60
+ try {
61
+ let mcpConfigPath: string | undefined;
62
+ if (deps.config.mcpServers && Object.keys(deps.config.mcpServers).length > 0) {
63
+ mcpConfigPath = join(tmpdir(), `mcp-${task.id}.json`);
64
+ await Bun.write(mcpConfigPath, JSON.stringify({ mcpServers: deps.config.mcpServers }));
65
+ }
66
+
67
+ const taskRunner = deps.getRunnerForRepo(task.repo);
68
+ const runOpts = deps.getRunnerOptsForRepo(task.repo, {
69
+ maxTurns: deps.config.claude.maxTurns,
70
+ mcpConfigPath,
71
+ signal: abortController.signal,
72
+ });
73
+
74
+ const result = await taskRunner.run(
75
+ task.prompt,
76
+ workDir,
77
+ runOpts,
78
+ (event: StatusEvent) => {
79
+ if (event.kind === "tool") {
80
+ const last = statusLog[statusLog.length - 1];
81
+ const summary = `Using ${event.tool}...`;
82
+ if (last !== summary) statusLog.push(summary);
83
+ } else {
84
+ statusLog.push(event.text.slice(0, 200));
85
+ }
86
+ originalMsg?.updateStatus(statusLog.slice(-5).join("\n"));
87
+ }
88
+ );
89
+
90
+ if (mcpConfigPath) {
91
+ try {
92
+ await unlink(mcpConfigPath);
93
+ } catch (err) {
94
+ logger.debug("failed to clean up mcp config", { error: String(err) });
95
+ }
96
+ }
97
+
98
+ if (result.success) {
99
+ deps.queue.complete(task.id, result.output);
100
+ logger.info("task completed", { taskId: task.id, durationMs: result.durationMs });
101
+
102
+ const platform = originalMsg?.platform || "slack";
103
+ const parts = splitAndReply(result.output, platform);
104
+ for (const part of parts) {
105
+ await originalMsg?.reply(part);
106
+ }
107
+ deps.sessions.addMessage(task.userId, "assistant", result.output.slice(0, 500));
108
+
109
+ const eventReply = deps.pendingEventReplies.get(task.id);
110
+ if (eventReply) {
111
+ await eventReply.adapter.respondToEvent(eventReply.event.eventId, result.output);
112
+ deps.pendingEventReplies.delete(task.id);
113
+ }
114
+ } else {
115
+ deps.queue.fail(task.id, result.output);
116
+ logger.error("task failed", { taskId: task.id });
117
+ await originalMsg?.reply(`Task failed: ${result.output.slice(0, 500)}`);
118
+ deps.sessions.addMessage(task.userId, "assistant", `Task failed: ${result.output.slice(0, 200)}`);
119
+
120
+ const eventReply = deps.pendingEventReplies.get(task.id);
121
+ if (eventReply) {
122
+ await eventReply.adapter.respondToEvent(eventReply.event.eventId, `Task failed: ${result.output.slice(0, 500)}`);
123
+ deps.pendingEventReplies.delete(task.id);
124
+ }
125
+ }
126
+ } finally {
127
+ if (!isCreateProject) {
128
+ await deps.repos.removeWorktree(task.repo, task.id).catch(() => {});
129
+ }
130
+ }
131
+ } catch (err) {
132
+ deps.queue.fail(task.id, String(err));
133
+ logger.error("task processing error", { taskId: task.id, error: String(err) });
134
+ await originalMsg?.reply(`Task error: ${String(err).slice(0, 500)}`);
135
+ } finally {
136
+ deps.runningProcesses.delete(task.id);
137
+ deps.pendingReplies.delete(task.id);
138
+ }
139
+ }
140
+
141
+ export function createWorker(deps: WorkerDeps): { start: () => void; cancel: (id: string) => boolean } {
142
+ async function workerLoop() {
143
+ const maxConcurrent = 5;
144
+
145
+ while (true) {
146
+ if (deps.runningProcesses.size < maxConcurrent) {
147
+ try {
148
+ const task = deps.queue.dequeue();
149
+ if (task) {
150
+ processTask(task, deps).catch((err) =>
151
+ logger.error("worker task error", { taskId: task.id, error: String(err) })
152
+ );
153
+ continue;
154
+ }
155
+ } catch (err) {
156
+ logger.error("worker loop error", { error: String(err) });
157
+ }
158
+ }
159
+ await Bun.sleep(2000);
160
+ }
161
+ }
162
+
163
+ return {
164
+ start: () => { workerLoop(); },
165
+ cancel: (id: string) => {
166
+ const entry = deps.runningProcesses.get(id);
167
+ if (!entry) return false;
168
+ entry.abort.abort();
169
+ deps.queue.cancel(entry.task.id);
170
+ return true;
171
+ },
172
+ };
173
+ }