@lovenyberg/ove 0.2.2 → 0.4.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
 
@@ -132,6 +143,28 @@ export class TaskQueue {
132
143
  return result.changes > 0;
133
144
  }
134
145
 
146
+ listRecentFailed(limit: number = 20): Task[] {
147
+ const rows = this.db
148
+ .query(
149
+ `SELECT * FROM tasks WHERE status = 'failed' ORDER BY completed_at DESC LIMIT ?`
150
+ )
151
+ .all(limit) as TaskRow[];
152
+ return rows.map((r) => this.rowToTask(r));
153
+ }
154
+
155
+ listRecent(limit: number = 20, status?: string): Task[] {
156
+ let sql = `SELECT * FROM tasks`;
157
+ const params: (string | number)[] = [];
158
+ if (status) {
159
+ sql += ` WHERE status = ?`;
160
+ params.push(status);
161
+ }
162
+ sql += ` ORDER BY created_at DESC LIMIT ?`;
163
+ params.push(limit);
164
+ const rows = this.db.query(sql).all(...params) as TaskRow[];
165
+ return rows.map((r) => this.rowToTask(r));
166
+ }
167
+
135
168
  resetStale(): number {
136
169
  const result = this.db.run(
137
170
  `UPDATE tasks SET status = 'failed', result = 'Interrupted — process restarted', completed_at = ? WHERE status = 'running'`,
@@ -140,13 +173,13 @@ export class TaskQueue {
140
173
  return result.changes;
141
174
  }
142
175
 
143
- private rowToTask(row: any): Task {
176
+ private rowToTask(row: TaskRow): Task {
144
177
  return {
145
178
  id: row.id,
146
179
  userId: row.user_id,
147
180
  repo: row.repo,
148
181
  prompt: row.prompt,
149
- status: row.status,
182
+ status: row.status as "pending" | "running" | "completed" | "failed",
150
183
  result: row.result,
151
184
  taskType: row.task_type || null,
152
185
  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
  }
package/src/router.ts CHANGED
@@ -15,7 +15,8 @@ export type MessageType =
15
15
  | "list-schedules"
16
16
  | "remove-schedule"
17
17
  | "list-tasks"
18
- | "cancel-task";
18
+ | "cancel-task"
19
+ | "trace";
19
20
 
20
21
  export interface ParsedMessage {
21
22
  type: MessageType;
@@ -45,6 +46,9 @@ export function parseMessage(text: string): ParsedMessage {
45
46
  const cancelMatch = trimmed.match(/^(?:\/)?cancel\s+(\S+)$/i);
46
47
  if (cancelMatch) return { type: "cancel-task", args: { taskId: cancelMatch[1] }, rawText: trimmed };
47
48
 
49
+ const traceMatch = trimmed.match(/^(?:\/)?trace(?:\s+(\S+))?$/i);
50
+ if (traceMatch) return { type: "trace", args: { taskId: traceMatch[1] }, rawText: trimmed };
51
+
48
52
  // Natural language status inquiries — short messages asking about progress
49
53
  if (isStatusInquiry(lower)) return { type: "status", args: {}, rawText: trimmed };
50
54
 
@@ -142,6 +146,8 @@ export function buildCronPrompt(prompt: string): string {
142
146
  return `This is an autonomous scheduled task. Do not ask questions — make your own decisions and proceed with the work. If there are multiple options, pick the best one and go.\n\n${prompt}`;
143
147
  }
144
148
 
149
+ const CHAT_PIPELINE_HINT = "You are running in a chat pipeline — your text output is sent to the user via a messaging app. Do NOT use AskUserQuestion or other interactive CLI tools (they don't work here). If you need to ask the user something, include the question and numbered options directly in your text response — the user will reply in chat and you'll see their answer in the next message.";
150
+
145
151
  export function buildContextualPrompt(
146
152
  parsed: ParsedMessage,
147
153
  history: { role: string; content: string }[],
@@ -152,7 +158,7 @@ export function buildContextualPrompt(
152
158
  history.slice(0, -1).map((m) => `${m.role}: ${m.content}`).join("\n") +
153
159
  "\n\nCurrent request:\n"
154
160
  : "";
155
- return persona + "\n\n" + contextPrefix + buildPrompt(parsed);
161
+ return persona + "\n\n" + CHAT_PIPELINE_HINT + "\n\n" + contextPrefix + buildPrompt(parsed);
156
162
  }
157
163
 
158
164
  export function buildPrompt(parsed: ParsedMessage): string {
@@ -3,6 +3,20 @@ import { logger } from "../logger";
3
3
  import { which } from "bun";
4
4
  import { realpathSync } from "node:fs";
5
5
 
6
+ const TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
7
+
8
+ function withTimeout(proc: { exited: Promise<number>; kill(): void }): Promise<number | "timeout"> {
9
+ return Promise.race([
10
+ proc.exited,
11
+ new Promise<"timeout">((resolve) =>
12
+ setTimeout(() => {
13
+ proc.kill();
14
+ resolve("timeout");
15
+ }, TIMEOUT_MS)
16
+ ),
17
+ ]);
18
+ }
19
+
6
20
  export function summarizeToolInput(name: string, input: any): string {
7
21
  if (!input) return "";
8
22
  switch (name) {
@@ -36,7 +50,7 @@ export class ClaudeRunner implements AgentRunner {
36
50
 
37
51
  buildArgs(prompt: string, opts: RunOptions): string[] {
38
52
  // stream-json requires --verbose in claude CLI
39
- const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(opts.maxTurns), "--dangerously-skip-permissions"];
53
+ const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(opts.maxTurns), "--dangerously-skip-permissions", "--disallowed-tools", "AskUserQuestion"];
40
54
  if (opts.mcpConfigPath) args.push("--mcp-config", opts.mcpConfigPath);
41
55
  return args;
42
56
  }
@@ -58,7 +72,7 @@ export class ClaudeRunner implements AgentRunner {
58
72
  }
59
73
 
60
74
  let resultText: string | null = null;
61
- let lastTextBlock: string | null = null;
75
+ const textBlocks: string[] = [];
62
76
  const decoder = new TextDecoder();
63
77
  const reader = proc.stdout.getReader();
64
78
  try {
@@ -76,7 +90,7 @@ export class ClaudeRunner implements AgentRunner {
76
90
  if (msg.type === "assistant" && msg.message?.content) {
77
91
  for (const block of msg.message.content) {
78
92
  if (block.type === "text") {
79
- lastTextBlock = block.text;
93
+ textBlocks.push(block.text);
80
94
  if (onStatus) onStatus({ kind: "text", text: block.text });
81
95
  }
82
96
  if (block.type === "tool_use" && onStatus) {
@@ -88,23 +102,30 @@ export class ClaudeRunner implements AgentRunner {
88
102
  }
89
103
  }
90
104
  }
91
- } catch {}
105
+ } catch {
106
+ // Non-JSON line in stream output — skip
107
+ }
92
108
  }
93
109
  }
94
110
  } finally {
95
111
  reader.releaseLock();
96
112
  }
97
113
 
98
- const exitCode = await proc.exited;
114
+ const exitCode = await withTimeout(proc);
99
115
  const durationMs = Date.now() - startTime;
100
116
 
117
+ if (exitCode === "timeout") {
118
+ logger.error("claude task timed out", { durationMs });
119
+ return { success: false, output: `Claude task timed out after ${TIMEOUT_MS / 60000} minutes`, durationMs };
120
+ }
121
+
101
122
  if (exitCode !== 0) {
102
123
  const stderr = await new Response(proc.stderr).text();
103
124
  logger.error("claude task failed", { exitCode, stderr, durationMs });
104
125
  return { success: false, output: stderr || "Claude task failed", durationMs };
105
126
  }
106
127
 
107
- const finalOutput = resultText || lastTextBlock || "Task completed (no output)";
128
+ const finalOutput = resultText || textBlocks.join("\n\n") || "Task completed (no output)";
108
129
  logger.info("claude task completed", { durationMs });
109
130
  return { success: true, output: finalOutput, durationMs };
110
131
  }
@@ -8,6 +8,20 @@ import { logger } from "../logger";
8
8
  import { which } from "bun";
9
9
  import { realpathSync } from "node:fs";
10
10
 
11
+ const TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
12
+
13
+ function withTimeout(proc: { exited: Promise<number>; kill(): void }): Promise<number | "timeout"> {
14
+ return Promise.race([
15
+ proc.exited,
16
+ new Promise<"timeout">((resolve) =>
17
+ setTimeout(() => {
18
+ proc.kill();
19
+ resolve("timeout");
20
+ }, TIMEOUT_MS)
21
+ ),
22
+ ]);
23
+ }
24
+
11
25
  export function summarizeCodexItem(
12
26
  item: any
13
27
  ): { tool: string; input: string } | null {
@@ -116,16 +130,23 @@ export class CodexRunner implements AgentRunner {
116
130
  if (event.type === "turn.failed") {
117
131
  errorMessage = event.error?.message || "Turn failed";
118
132
  }
119
- } catch {}
133
+ } catch {
134
+ // Non-JSON line in stream output — skip
135
+ }
120
136
  }
121
137
  }
122
138
  } finally {
123
139
  reader.releaseLock();
124
140
  }
125
141
 
126
- const exitCode = await proc.exited;
142
+ const exitCode = await withTimeout(proc);
127
143
  const durationMs = Date.now() - startTime;
128
144
 
145
+ if (exitCode === "timeout") {
146
+ logger.error("codex task timed out", { durationMs });
147
+ return { success: false, output: `Codex task timed out after ${TIMEOUT_MS / 60000} minutes`, durationMs };
148
+ }
149
+
129
150
  if (exitCode !== 0) {
130
151
  const stderr = await new Response(proc.stderr).text();
131
152
  const output = errorMessage || stderr || "Codex task failed";
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/trace.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { Database } from "bun:sqlite";
2
+
3
+ export interface TraceEvent {
4
+ id: number;
5
+ taskId: string;
6
+ ts: string;
7
+ kind: "status" | "tool" | "lifecycle" | "output" | "error";
8
+ summary: string;
9
+ detail: string | null;
10
+ }
11
+
12
+ interface TraceRow {
13
+ id: number;
14
+ task_id: string;
15
+ ts: string;
16
+ kind: string;
17
+ summary: string;
18
+ detail: string | null;
19
+ }
20
+
21
+ export class TraceStore {
22
+ private db: Database;
23
+ private enabled: boolean;
24
+
25
+ constructor(db: Database) {
26
+ this.db = db;
27
+ this.enabled = process.env.OVE_TRACE === "true";
28
+ this.db.run(`
29
+ CREATE TABLE IF NOT EXISTS task_traces (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ task_id TEXT NOT NULL,
32
+ ts TEXT NOT NULL,
33
+ kind TEXT NOT NULL,
34
+ summary TEXT NOT NULL,
35
+ detail TEXT
36
+ )
37
+ `);
38
+ this.db.run(`CREATE INDEX IF NOT EXISTS idx_trace_task_id ON task_traces(task_id)`);
39
+ }
40
+
41
+ isEnabled(): boolean {
42
+ return this.enabled;
43
+ }
44
+
45
+ append(taskId: string, kind: TraceEvent["kind"], summary: string, detail?: string) {
46
+ if (!this.enabled) return;
47
+ this.db.run(
48
+ `INSERT INTO task_traces (task_id, ts, kind, summary, detail) VALUES (?, ?, ?, ?, ?)`,
49
+ [taskId, new Date().toISOString(), kind, summary, detail ?? null]
50
+ );
51
+ }
52
+
53
+ getByTask(taskId: string, limit: number = 100): TraceEvent[] {
54
+ const rows = this.db
55
+ .query(`SELECT * FROM task_traces WHERE task_id = ? ORDER BY id ASC LIMIT ?`)
56
+ .all(taskId, limit) as TraceRow[];
57
+ return rows.map((r) => ({
58
+ id: r.id,
59
+ taskId: r.task_id,
60
+ ts: r.ts,
61
+ kind: r.kind as TraceEvent["kind"],
62
+ summary: r.summary,
63
+ detail: r.detail,
64
+ }));
65
+ }
66
+
67
+ cleanup(olderThanDays: number = 7) {
68
+ const cutoff = new Date(Date.now() - olderThanDays * 86_400_000).toISOString();
69
+ this.db.run(`DELETE FROM task_traces WHERE ts < ?`, [cutoff]);
70
+ }
71
+ }