@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/package.json +1 -1
- package/src/adapters/debounce.ts +10 -0
- package/src/adapters/discord.ts +1 -11
- package/src/adapters/github.ts +12 -0
- package/src/adapters/http.ts +11 -3
- package/src/adapters/slack.ts +1 -11
- package/src/adapters/telegram.ts +1 -11
- package/src/config.ts +4 -1
- package/src/handlers.ts +512 -0
- package/src/index.ts +50 -647
- package/src/queue.ts +22 -11
- package/src/repo-registry.ts +23 -5
- package/src/runners/claude.ts +3 -1
- package/src/runners/codex.ts +3 -1
- package/src/schedules.ts +13 -3
- package/src/sessions.ts +8 -2
- package/src/worker.ts +173 -0
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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,
|
package/src/repo-registry.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
}
|
package/src/runners/claude.ts
CHANGED
package/src/runners/codex.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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
|
|
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
|
+
}
|