ework-daemon 0.1.2 → 0.2.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 +3 -1
- package/src/config.ts +41 -6
- package/src/db.ts +375 -0
- package/src/index.ts +43 -15
- package/src/op.ts +267 -278
- package/src/opencode.ts +369 -155
- package/src/schema-mysql.sql +74 -0
- package/src/schema-sqlite.sql +68 -0
- package/src/server.ts +14 -14
- package/src/trackers/types.ts +8 -0
package/src/op.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import { Database } from "bun:sqlite";
|
|
2
|
-
import { mkdirSync, existsSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
4
|
-
import { homedir } from "os";
|
|
5
1
|
import { log } from "./logger";
|
|
2
|
+
import { getDB } from "./db";
|
|
6
3
|
import type { TrackerRef, Issue, IssueState, OpSession, SessionState, Message } from "./trackers/types";
|
|
7
4
|
|
|
8
5
|
// ─── Row Types ───
|
|
@@ -17,6 +14,7 @@ interface IssueRow {
|
|
|
17
14
|
title: string;
|
|
18
15
|
created_at: string;
|
|
19
16
|
updated_at: string;
|
|
17
|
+
owner_daemon_id: number | null;
|
|
20
18
|
}
|
|
21
19
|
|
|
22
20
|
interface SessionRow {
|
|
@@ -32,6 +30,10 @@ interface SessionRow {
|
|
|
32
30
|
progress_comment_id: string | null;
|
|
33
31
|
reaction_comment_id: string | null;
|
|
34
32
|
current_prompt: string | null;
|
|
33
|
+
last_output_at: string | null;
|
|
34
|
+
nudge_rounds: number;
|
|
35
|
+
stuck_nudge_rounds: number;
|
|
36
|
+
generation: number;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
interface MessageRow {
|
|
@@ -62,6 +64,7 @@ function rowToIssue(row: IssueRow): Issue {
|
|
|
62
64
|
title: row.title,
|
|
63
65
|
createdAt: new Date(row.created_at),
|
|
64
66
|
updatedAt: new Date(row.updated_at),
|
|
67
|
+
ownerDaemonId: row.owner_daemon_id ?? null,
|
|
65
68
|
};
|
|
66
69
|
}
|
|
67
70
|
|
|
@@ -79,6 +82,10 @@ function rowToSession(row: SessionRow): OpSession {
|
|
|
79
82
|
progressCommentId: row.progress_comment_id ?? undefined,
|
|
80
83
|
reactionCommentId: row.reaction_comment_id ?? undefined,
|
|
81
84
|
currentPrompt: row.current_prompt ?? undefined,
|
|
85
|
+
lastOutputAt: row.last_output_at ? new Date(row.last_output_at).getTime() : undefined,
|
|
86
|
+
nudgeRounds: row.nudge_rounds ?? 0,
|
|
87
|
+
stuckNudgeRounds: row.stuck_nudge_rounds ?? 0,
|
|
88
|
+
generation: row.generation ?? 0,
|
|
82
89
|
};
|
|
83
90
|
}
|
|
84
91
|
|
|
@@ -105,233 +112,36 @@ export function sessionToTrackerRef(session: OpSession, issue: Issue): TrackerRe
|
|
|
105
112
|
};
|
|
106
113
|
}
|
|
107
114
|
|
|
108
|
-
// ─── Store (
|
|
115
|
+
// ─── Store (async DAO over the global AsyncDatabase from db.ts) ───
|
|
109
116
|
|
|
110
117
|
export class Store {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
private issueStmts!: {
|
|
114
|
-
getById: ReturnType<Database["prepare"]>;
|
|
115
|
-
getByTrackerRef: ReturnType<Database["prepare"]>;
|
|
116
|
-
insert: ReturnType<Database["prepare"]>;
|
|
117
|
-
updateState: ReturnType<Database["prepare"]>;
|
|
118
|
-
updateTitle: ReturnType<Database["prepare"]>;
|
|
119
|
-
listActive: ReturnType<Database["prepare"]>;
|
|
120
|
-
listAll: ReturnType<Database["prepare"]>;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
private sessionStmts!: {
|
|
124
|
-
getById: ReturnType<Database["prepare"]>;
|
|
125
|
-
getByName: ReturnType<Database["prepare"]>;
|
|
126
|
-
getByIssue: ReturnType<Database["prepare"]>;
|
|
127
|
-
insert: ReturnType<Database["prepare"]>;
|
|
128
|
-
update: ReturnType<Database["prepare"]>;
|
|
129
|
-
listAll: ReturnType<Database["prepare"]>;
|
|
130
|
-
listNonIdle: ReturnType<Database["prepare"]>;
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
private msgStmts!: {
|
|
134
|
-
getById: ReturnType<Database["prepare"]>;
|
|
135
|
-
insert: ReturnType<Database["prepare"]>;
|
|
136
|
-
getNextPending: ReturnType<Database["prepare"]>;
|
|
137
|
-
updateStatus: ReturnType<Database["prepare"]>;
|
|
138
|
-
updateStatusSelect: ReturnType<Database["prepare"]>;
|
|
139
|
-
getBySession: ReturnType<Database["prepare"]>;
|
|
140
|
-
getPendingOrRunning: ReturnType<Database["prepare"]>;
|
|
141
|
-
findByCommentId: ReturnType<Database["prepare"]>;
|
|
142
|
-
getRecentBySession: ReturnType<Database["prepare"]>;
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
constructor(dbPath?: string) {
|
|
146
|
-
const resolved = dbPath ?? join(
|
|
147
|
-
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
|
|
148
|
-
"ework-daemon",
|
|
149
|
-
"ework-daemon.db"
|
|
150
|
-
);
|
|
151
|
-
const dir = join(resolved, "..");
|
|
152
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
153
|
-
|
|
154
|
-
this.db = new Database(resolved, { create: true });
|
|
155
|
-
this.db.exec("PRAGMA journal_mode = WAL");
|
|
156
|
-
this.db.exec("PRAGMA foreign_keys = ON");
|
|
157
|
-
|
|
158
|
-
this.initSchema();
|
|
159
|
-
this.migrateOldSchema();
|
|
160
|
-
this.prepareStatements();
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
private initSchema() {
|
|
164
|
-
// Drop old tables with incompatible schemas
|
|
165
|
-
const oldMessages = this.db.prepare(
|
|
166
|
-
"SELECT sql FROM sqlite_master WHERE type='table' AND name='messages'"
|
|
167
|
-
).get() as { sql: string } | null;
|
|
168
|
-
if (oldMessages && oldMessages.sql.includes('op_id')) {
|
|
169
|
-
log.info("store: dropping old messages table (incompatible schema)");
|
|
170
|
-
this.db.exec("DROP TABLE messages");
|
|
171
|
-
}
|
|
172
|
-
const oldSessions = this.db.prepare(
|
|
173
|
-
"SELECT sql FROM sqlite_master WHERE type='table' AND name='sessions'"
|
|
174
|
-
).get() as { sql: string } | null;
|
|
175
|
-
if (oldSessions) {
|
|
176
|
-
log.info("store: dropping old sessions table");
|
|
177
|
-
this.db.exec("DROP TABLE IF EXISTS sessions");
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
this.db.exec(`
|
|
181
|
-
CREATE TABLE IF NOT EXISTS issues (
|
|
182
|
-
id TEXT PRIMARY KEY,
|
|
183
|
-
tracker_type TEXT NOT NULL,
|
|
184
|
-
tracker_scope_key TEXT NOT NULL,
|
|
185
|
-
tracker_scope TEXT NOT NULL,
|
|
186
|
-
tracker_issue_id TEXT NOT NULL,
|
|
187
|
-
state TEXT NOT NULL DEFAULT 'created',
|
|
188
|
-
title TEXT NOT NULL DEFAULT '',
|
|
189
|
-
created_at TEXT NOT NULL,
|
|
190
|
-
updated_at TEXT NOT NULL,
|
|
191
|
-
UNIQUE(tracker_type, tracker_scope_key, tracker_issue_id)
|
|
192
|
-
)
|
|
193
|
-
`);
|
|
194
|
-
|
|
195
|
-
this.db.exec(`
|
|
196
|
-
CREATE TABLE IF NOT EXISTS op_sessions (
|
|
197
|
-
id TEXT PRIMARY KEY,
|
|
198
|
-
issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
|
199
|
-
name TEXT NOT NULL,
|
|
200
|
-
state TEXT NOT NULL DEFAULT 'idle',
|
|
201
|
-
opencode_session_id TEXT,
|
|
202
|
-
opencode_pid INTEGER,
|
|
203
|
-
workdir TEXT,
|
|
204
|
-
created_at TEXT NOT NULL,
|
|
205
|
-
started_at INTEGER,
|
|
206
|
-
progress_comment_id TEXT,
|
|
207
|
-
reaction_comment_id TEXT,
|
|
208
|
-
current_prompt TEXT,
|
|
209
|
-
UNIQUE(issue_id, name)
|
|
210
|
-
)
|
|
211
|
-
`);
|
|
212
|
-
|
|
213
|
-
this.db.exec(`
|
|
214
|
-
CREATE TABLE IF NOT EXISTS messages (
|
|
215
|
-
id TEXT PRIMARY KEY,
|
|
216
|
-
session_id TEXT NOT NULL REFERENCES op_sessions(id) ON DELETE CASCADE,
|
|
217
|
-
content TEXT NOT NULL,
|
|
218
|
-
source_comment_id TEXT,
|
|
219
|
-
reaction_comment_id TEXT,
|
|
220
|
-
status TEXT NOT NULL DEFAULT 'pending',
|
|
221
|
-
attempts INTEGER NOT NULL DEFAULT 0,
|
|
222
|
-
error TEXT,
|
|
223
|
-
created_at TEXT NOT NULL,
|
|
224
|
-
updated_at TEXT NOT NULL
|
|
225
|
-
)
|
|
226
|
-
`);
|
|
227
|
-
|
|
228
|
-
this.db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_issue ON op_sessions(issue_id)");
|
|
229
|
-
this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id)");
|
|
230
|
-
this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status)");
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
private migrateOldSchema() {
|
|
234
|
-
const hasOldOps = this.db.prepare(
|
|
235
|
-
"SELECT name FROM sqlite_master WHERE type='table' AND name='ops'"
|
|
236
|
-
).get();
|
|
237
|
-
if (!hasOldOps) return;
|
|
238
|
-
|
|
239
|
-
log.info("store: migrating old ops table to issues + op_sessions");
|
|
240
|
-
|
|
241
|
-
this.db.exec(`
|
|
242
|
-
INSERT OR IGNORE INTO issues (id, tracker_type, tracker_scope_key, tracker_scope, tracker_issue_id, state, title, created_at, updated_at)
|
|
243
|
-
SELECT
|
|
244
|
-
lower(hex(randomblob(4))),
|
|
245
|
-
tracker_type, tracker_scope_key, tracker_scope, tracker_issue_id,
|
|
246
|
-
CASE WHEN status = 'closed' THEN 'closed' ELSE 'active' END,
|
|
247
|
-
'',
|
|
248
|
-
created_at, last_activity_at
|
|
249
|
-
FROM ops
|
|
250
|
-
WHERE tracker_type IS NOT NULL
|
|
251
|
-
`);
|
|
252
|
-
|
|
253
|
-
this.db.exec(`DROP TABLE IF EXISTS ops`);
|
|
254
|
-
this.db.exec(`DROP TABLE IF EXISTS sessions`);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
private prepareStatements() {
|
|
258
|
-
this.issueStmts = {
|
|
259
|
-
getById: this.db.prepare("SELECT * FROM issues WHERE id = ?"),
|
|
260
|
-
getByTrackerRef: this.db.prepare(
|
|
261
|
-
"SELECT * FROM issues WHERE tracker_type = ? AND tracker_scope_key = ? AND tracker_issue_id = ?"
|
|
262
|
-
),
|
|
263
|
-
insert: this.db.prepare(
|
|
264
|
-
"INSERT OR IGNORE INTO issues (id, tracker_type, tracker_scope_key, tracker_scope, tracker_issue_id, state, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
265
|
-
),
|
|
266
|
-
updateState: this.db.prepare("UPDATE issues SET state = ?, updated_at = ? WHERE id = ?"),
|
|
267
|
-
updateTitle: this.db.prepare("UPDATE issues SET title = ?, updated_at = ? WHERE id = ?"),
|
|
268
|
-
listActive: this.db.prepare("SELECT * FROM issues WHERE state != 'closed'"),
|
|
269
|
-
listAll: this.db.prepare("SELECT * FROM issues"),
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
this.sessionStmts = {
|
|
273
|
-
getById: this.db.prepare("SELECT * FROM op_sessions WHERE id = ?"),
|
|
274
|
-
getByName: this.db.prepare(
|
|
275
|
-
"SELECT * FROM op_sessions WHERE issue_id = ? AND name = ?"
|
|
276
|
-
),
|
|
277
|
-
getByIssue: this.db.prepare(
|
|
278
|
-
"SELECT * FROM op_sessions WHERE issue_id = ? ORDER BY created_at"
|
|
279
|
-
),
|
|
280
|
-
insert: this.db.prepare(
|
|
281
|
-
"INSERT OR IGNORE INTO op_sessions (id, issue_id, name, state, opencode_session_id, opencode_pid, workdir, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
282
|
-
),
|
|
283
|
-
update: this.db.prepare(
|
|
284
|
-
`UPDATE op_sessions SET state = ?, opencode_session_id = ?, opencode_pid = ?, workdir = ?,
|
|
285
|
-
started_at = ?, progress_comment_id = ?, reaction_comment_id = ?, current_prompt = ? WHERE id = ?`
|
|
286
|
-
),
|
|
287
|
-
listAll: this.db.prepare("SELECT * FROM op_sessions"),
|
|
288
|
-
listNonIdle: this.db.prepare("SELECT * FROM op_sessions WHERE state != 'idle'"),
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
this.msgStmts = {
|
|
292
|
-
getById: this.db.prepare("SELECT * FROM messages WHERE id = ?"),
|
|
293
|
-
insert: this.db.prepare(
|
|
294
|
-
"INSERT OR IGNORE INTO messages (id, session_id, content, source_comment_id, reaction_comment_id, status, attempts, error, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
295
|
-
),
|
|
296
|
-
getNextPending: this.db.prepare(
|
|
297
|
-
"SELECT * FROM messages WHERE session_id = ? AND status = 'pending' ORDER BY created_at ASC LIMIT 1"
|
|
298
|
-
),
|
|
299
|
-
updateStatus: this.db.prepare(
|
|
300
|
-
"UPDATE messages SET status = ?, attempts = ?, error = ?, updated_at = ? WHERE id = ?"
|
|
301
|
-
),
|
|
302
|
-
updateStatusSelect: this.db.prepare("SELECT * FROM messages WHERE id = ?"),
|
|
303
|
-
getBySession: this.db.prepare(
|
|
304
|
-
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at DESC"
|
|
305
|
-
),
|
|
306
|
-
getPendingOrRunning: this.db.prepare(
|
|
307
|
-
"SELECT * FROM messages WHERE status IN ('pending', 'running') ORDER BY created_at ASC"
|
|
308
|
-
),
|
|
309
|
-
findByCommentId: this.db.prepare(
|
|
310
|
-
"SELECT * FROM messages WHERE source_comment_id = ?"
|
|
311
|
-
),
|
|
312
|
-
getRecentBySession: this.db.prepare(
|
|
313
|
-
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ?"
|
|
314
|
-
),
|
|
315
|
-
};
|
|
316
|
-
}
|
|
118
|
+
// No constructor work: the DB is opened globally by initDB() at boot.
|
|
119
|
+
// Tests rely on tests/setup.ts to call initDB() in beforeAll.
|
|
317
120
|
|
|
318
121
|
// ─── Issues ───
|
|
319
122
|
|
|
320
|
-
getIssue(id: string): Issue | undefined {
|
|
321
|
-
const row =
|
|
123
|
+
async getIssue(id: string): Promise<Issue | undefined> {
|
|
124
|
+
const row = await getDB().get<IssueRow>("SELECT * FROM {{issues}} WHERE id = ?", [id]);
|
|
322
125
|
return row ? rowToIssue(row) : undefined;
|
|
323
126
|
}
|
|
324
127
|
|
|
325
|
-
findIssue(trackerType: string, scopeKey: string, issueId: string): Issue | undefined {
|
|
326
|
-
const row =
|
|
128
|
+
async findIssue(trackerType: string, scopeKey: string, issueId: string): Promise<Issue | undefined> {
|
|
129
|
+
const row = await getDB().get<IssueRow>(
|
|
130
|
+
"SELECT * FROM {{issues}} WHERE tracker_type = ? AND tracker_scope_key = ? AND tracker_issue_id = ?",
|
|
131
|
+
[trackerType, scopeKey, issueId]
|
|
132
|
+
);
|
|
327
133
|
return row ? rowToIssue(row) : undefined;
|
|
328
134
|
}
|
|
329
135
|
|
|
330
|
-
findOrCreateIssue(ref: TrackerRef, scopeKey: string, title: string): Issue {
|
|
331
|
-
const existing = this.findIssue(ref.trackerType, scopeKey, ref.issueId);
|
|
136
|
+
async findOrCreateIssue(ref: TrackerRef, scopeKey: string, title: string): Promise<Issue> {
|
|
137
|
+
const existing = await this.findIssue(ref.trackerType, scopeKey, ref.issueId);
|
|
332
138
|
if (existing) {
|
|
333
139
|
if (title && existing.title !== title) {
|
|
334
|
-
|
|
140
|
+
await getDB().run("UPDATE {{issues}} SET title = ?, updated_at = ? WHERE id = ?", [
|
|
141
|
+
title,
|
|
142
|
+
new Date().toISOString(),
|
|
143
|
+
existing.id,
|
|
144
|
+
]);
|
|
335
145
|
existing.title = title;
|
|
336
146
|
}
|
|
337
147
|
return existing;
|
|
@@ -339,14 +149,13 @@ export class Store {
|
|
|
339
149
|
|
|
340
150
|
const now = new Date();
|
|
341
151
|
const id = crypto.randomUUID();
|
|
342
|
-
|
|
343
|
-
id,
|
|
344
|
-
JSON.stringify(ref.scope), ref.issueId,
|
|
345
|
-
"created", title, now.toISOString(), now.toISOString()
|
|
152
|
+
await getDB().run(
|
|
153
|
+
"INSERT OR IGNORE INTO {{issues}} (id, tracker_type, tracker_scope_key, tracker_scope, tracker_issue_id, state, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
154
|
+
[id, ref.trackerType, scopeKey, JSON.stringify(ref.scope), ref.issueId, "created", title, now.toISOString(), now.toISOString()]
|
|
346
155
|
);
|
|
347
156
|
|
|
348
157
|
// Re-read in case INSERT OR IGNORE hit a concurrent insert
|
|
349
|
-
const inserted = this.findIssue(ref.trackerType, scopeKey, ref.issueId);
|
|
158
|
+
const inserted = await this.findIssue(ref.trackerType, scopeKey, ref.issueId);
|
|
350
159
|
if (inserted) return inserted;
|
|
351
160
|
|
|
352
161
|
return {
|
|
@@ -356,36 +165,49 @@ export class Store {
|
|
|
356
165
|
};
|
|
357
166
|
}
|
|
358
167
|
|
|
359
|
-
updateIssueState(id: string, state: IssueState) {
|
|
360
|
-
|
|
168
|
+
async updateIssueState(id: string, state: IssueState): Promise<void> {
|
|
169
|
+
await getDB().run("UPDATE {{issues}} SET state = ?, updated_at = ? WHERE id = ?", [
|
|
170
|
+
state,
|
|
171
|
+
new Date().toISOString(),
|
|
172
|
+
id,
|
|
173
|
+
]);
|
|
361
174
|
}
|
|
362
175
|
|
|
363
|
-
listActiveIssues(): Issue[] {
|
|
364
|
-
|
|
176
|
+
async listActiveIssues(): Promise<Issue[]> {
|
|
177
|
+
const rows = await getDB().all<IssueRow>("SELECT * FROM {{issues}} WHERE state != 'closed'");
|
|
178
|
+
return rows.map(rowToIssue);
|
|
365
179
|
}
|
|
366
180
|
|
|
367
|
-
listAllIssues(): Issue[] {
|
|
368
|
-
|
|
181
|
+
async listAllIssues(): Promise<Issue[]> {
|
|
182
|
+
const rows = await getDB().all<IssueRow>("SELECT * FROM {{issues}}");
|
|
183
|
+
return rows.map(rowToIssue);
|
|
369
184
|
}
|
|
370
185
|
|
|
371
186
|
// ─── OpSessions ───
|
|
372
187
|
|
|
373
|
-
getSession(id: string): OpSession | undefined {
|
|
374
|
-
const row =
|
|
188
|
+
async getSession(id: string): Promise<OpSession | undefined> {
|
|
189
|
+
const row = await getDB().get<SessionRow>("SELECT * FROM {{op_sessions}} WHERE id = ?", [id]);
|
|
375
190
|
return row ? rowToSession(row) : undefined;
|
|
376
191
|
}
|
|
377
192
|
|
|
378
|
-
getSessionByName(issueId: string, name: string): OpSession | undefined {
|
|
379
|
-
const row =
|
|
193
|
+
async getSessionByName(issueId: string, name: string): Promise<OpSession | undefined> {
|
|
194
|
+
const row = await getDB().get<SessionRow>(
|
|
195
|
+
"SELECT * FROM {{op_sessions}} WHERE issue_id = ? AND name = ?",
|
|
196
|
+
[issueId, name]
|
|
197
|
+
);
|
|
380
198
|
return row ? rowToSession(row) : undefined;
|
|
381
199
|
}
|
|
382
200
|
|
|
383
|
-
getSessionsForIssue(issueId: string): OpSession[] {
|
|
384
|
-
|
|
201
|
+
async getSessionsForIssue(issueId: string): Promise<OpSession[]> {
|
|
202
|
+
const rows = await getDB().all<SessionRow>(
|
|
203
|
+
"SELECT * FROM {{op_sessions}} WHERE issue_id = ? ORDER BY created_at",
|
|
204
|
+
[issueId]
|
|
205
|
+
);
|
|
206
|
+
return rows.map(rowToSession);
|
|
385
207
|
}
|
|
386
208
|
|
|
387
|
-
createSession(issueId: string, name: string): OpSession {
|
|
388
|
-
const existing = this.getSessionByName(issueId, name);
|
|
209
|
+
async createSession(issueId: string, name: string): Promise<OpSession> {
|
|
210
|
+
const existing = await this.getSessionByName(issueId, name);
|
|
389
211
|
if (existing) return existing;
|
|
390
212
|
|
|
391
213
|
const session: OpSession = {
|
|
@@ -395,50 +217,66 @@ export class Store {
|
|
|
395
217
|
state: "idle",
|
|
396
218
|
createdAt: new Date(),
|
|
397
219
|
};
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
null, null, null, session.createdAt.toISOString()
|
|
220
|
+
await getDB().run(
|
|
221
|
+
"INSERT OR IGNORE INTO {{op_sessions}} (id, issue_id, name, state, opencode_session_id, opencode_pid, workdir, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
222
|
+
[session.id, session.issueId, session.name, session.state, null, null, null, session.createdAt.toISOString()]
|
|
401
223
|
);
|
|
402
224
|
return session;
|
|
403
225
|
}
|
|
404
226
|
|
|
405
|
-
updateSession(id: string, patch: Partial<OpSession>): OpSession | undefined {
|
|
406
|
-
const row =
|
|
227
|
+
async updateSession(id: string, patch: Partial<OpSession>): Promise<OpSession | undefined> {
|
|
228
|
+
const row = await getDB().get<SessionRow>("SELECT * FROM {{op_sessions}} WHERE id = ?", [id]);
|
|
407
229
|
if (!row) return undefined;
|
|
408
230
|
const existing = rowToSession(row);
|
|
409
231
|
const updated = { ...existing, ...patch };
|
|
410
232
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
233
|
+
await getDB().run(
|
|
234
|
+
`UPDATE {{op_sessions}} SET state = ?, opencode_session_id = ?, opencode_pid = ?, workdir = ?,
|
|
235
|
+
started_at = ?, progress_comment_id = ?, reaction_comment_id = ?, current_prompt = ?,
|
|
236
|
+
last_output_at = ?, nudge_rounds = ?, stuck_nudge_rounds = ?, generation = ? WHERE id = ?`,
|
|
237
|
+
[
|
|
238
|
+
updated.state,
|
|
239
|
+
updated.opencodeSessionId ?? null,
|
|
240
|
+
updated.opencodePid ?? null,
|
|
241
|
+
updated.workdir ?? null,
|
|
242
|
+
updated.startedAt ?? null,
|
|
243
|
+
updated.progressCommentId ?? null,
|
|
244
|
+
updated.reactionCommentId ?? null,
|
|
245
|
+
updated.currentPrompt ?? null,
|
|
246
|
+
updated.lastOutputAt != null ? new Date(updated.lastOutputAt).toISOString() : null,
|
|
247
|
+
updated.nudgeRounds ?? 0,
|
|
248
|
+
updated.stuckNudgeRounds ?? 0,
|
|
249
|
+
updated.generation ?? 0,
|
|
250
|
+
id,
|
|
251
|
+
]
|
|
421
252
|
);
|
|
422
253
|
return updated;
|
|
423
254
|
}
|
|
424
255
|
|
|
425
|
-
listAllSessions(): OpSession[] {
|
|
426
|
-
|
|
256
|
+
async listAllSessions(): Promise<OpSession[]> {
|
|
257
|
+
const rows = await getDB().all<SessionRow>("SELECT * FROM {{op_sessions}}");
|
|
258
|
+
return rows.map(rowToSession);
|
|
427
259
|
}
|
|
428
260
|
|
|
429
|
-
listNonIdleSessions(): OpSession[] {
|
|
430
|
-
|
|
261
|
+
async listNonIdleSessions(): Promise<OpSession[]> {
|
|
262
|
+
const rows = await getDB().all<SessionRow>("SELECT * FROM {{op_sessions}} WHERE state != 'idle'");
|
|
263
|
+
return rows.map(rowToSession);
|
|
431
264
|
}
|
|
432
265
|
|
|
433
266
|
// ─── Messages ───
|
|
434
267
|
|
|
435
|
-
createMessage(
|
|
268
|
+
async createMessage(
|
|
269
|
+
sessionId: string,
|
|
270
|
+
content: string,
|
|
271
|
+
sourceCommentId?: string,
|
|
272
|
+
reactionCommentId?: string,
|
|
273
|
+
model?: string,
|
|
274
|
+
): Promise<Message> {
|
|
436
275
|
const now = new Date().toISOString();
|
|
437
276
|
const id = crypto.randomUUID();
|
|
438
|
-
|
|
439
|
-
id,
|
|
440
|
-
sourceCommentId ?? null, reactionCommentId ?? null,
|
|
441
|
-
"pending", 0, null, now, now
|
|
277
|
+
await getDB().run(
|
|
278
|
+
"INSERT OR IGNORE INTO {{messages}} (id, session_id, content, source_comment_id, reaction_comment_id, status, attempts, error, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
279
|
+
[id, sessionId, content, sourceCommentId ?? null, reactionCommentId ?? null, "pending", 0, null, now, now]
|
|
442
280
|
);
|
|
443
281
|
return {
|
|
444
282
|
id, sessionId, content, sourceCommentId, reactionCommentId,
|
|
@@ -448,40 +286,191 @@ export class Store {
|
|
|
448
286
|
};
|
|
449
287
|
}
|
|
450
288
|
|
|
451
|
-
getMessage(id: string): Message | undefined {
|
|
452
|
-
const row =
|
|
289
|
+
async getMessage(id: string): Promise<Message | undefined> {
|
|
290
|
+
const row = await getDB().get<MessageRow>("SELECT * FROM {{messages}} WHERE id = ?", [id]);
|
|
453
291
|
return row ? rowToMessage(row) : undefined;
|
|
454
292
|
}
|
|
455
293
|
|
|
456
|
-
getNextPendingMessage(sessionId: string): Message | undefined {
|
|
457
|
-
const row =
|
|
294
|
+
async getNextPendingMessage(sessionId: string): Promise<Message | undefined> {
|
|
295
|
+
const row = await getDB().get<MessageRow>(
|
|
296
|
+
"SELECT * FROM {{messages}} WHERE session_id = ? AND status = 'pending' ORDER BY created_at ASC LIMIT 1",
|
|
297
|
+
[sessionId]
|
|
298
|
+
);
|
|
458
299
|
return row ? rowToMessage(row) : undefined;
|
|
459
300
|
}
|
|
460
301
|
|
|
461
|
-
updateMessageStatus(id: string, status: Message["status"], error?: string) {
|
|
462
|
-
const row =
|
|
302
|
+
async updateMessageStatus(id: string, status: Message["status"], error?: string): Promise<void> {
|
|
303
|
+
const row = await getDB().get<MessageRow>("SELECT * FROM {{messages}} WHERE id = ?", [id]);
|
|
463
304
|
const attempts = row ? row.attempts + (status === "failed" ? 1 : 0) : 0;
|
|
464
|
-
|
|
305
|
+
await getDB().run(
|
|
306
|
+
"UPDATE {{messages}} SET status = ?, attempts = ?, error = ?, updated_at = ? WHERE id = ?",
|
|
307
|
+
[status, attempts, error ?? null, new Date().toISOString(), id]
|
|
308
|
+
);
|
|
465
309
|
}
|
|
466
310
|
|
|
467
|
-
getMessagesForSession(sessionId: string): Message[] {
|
|
468
|
-
|
|
311
|
+
async getMessagesForSession(sessionId: string): Promise<Message[]> {
|
|
312
|
+
const rows = await getDB().all<MessageRow>(
|
|
313
|
+
"SELECT * FROM {{messages}} WHERE session_id = ? ORDER BY created_at DESC",
|
|
314
|
+
[sessionId]
|
|
315
|
+
);
|
|
316
|
+
return rows.map(rowToMessage);
|
|
469
317
|
}
|
|
470
318
|
|
|
471
|
-
getRecentMessages(sessionId: string, limit: number): Message[] {
|
|
472
|
-
|
|
319
|
+
async getRecentMessages(sessionId: string, limit: number): Promise<Message[]> {
|
|
320
|
+
const rows = await getDB().all<MessageRow>(
|
|
321
|
+
"SELECT * FROM {{messages}} WHERE session_id = ? ORDER BY created_at DESC LIMIT ?",
|
|
322
|
+
[sessionId, limit]
|
|
323
|
+
);
|
|
324
|
+
return rows.map(rowToMessage);
|
|
473
325
|
}
|
|
474
326
|
|
|
475
|
-
getPendingOrRunningMessages(): Message[] {
|
|
476
|
-
|
|
327
|
+
async getPendingOrRunningMessages(): Promise<Message[]> {
|
|
328
|
+
const rows = await getDB().all<MessageRow>(
|
|
329
|
+
"SELECT * FROM {{messages}} WHERE status IN ('pending', 'running') ORDER BY created_at ASC"
|
|
330
|
+
);
|
|
331
|
+
return rows.map(rowToMessage);
|
|
477
332
|
}
|
|
478
333
|
|
|
479
|
-
findMessageByCommentId(commentId: string): Message | undefined {
|
|
480
|
-
const row =
|
|
334
|
+
async findMessageByCommentId(commentId: string): Promise<Message | undefined> {
|
|
335
|
+
const row = await getDB().get<MessageRow>(
|
|
336
|
+
"SELECT * FROM {{messages}} WHERE source_comment_id = ?",
|
|
337
|
+
[commentId]
|
|
338
|
+
);
|
|
481
339
|
return row ? rowToMessage(row) : undefined;
|
|
482
340
|
}
|
|
483
341
|
|
|
484
|
-
|
|
485
|
-
|
|
342
|
+
// ─── Multi-machine coordination (Phase 1) ───
|
|
343
|
+
//
|
|
344
|
+
// daemon_id is a DB-allocated logical slot. A restarted daemon ADOPTS the
|
|
345
|
+
// oldest orphan slot (last_heartbeat older than the lease TTL) instead of
|
|
346
|
+
// inserting a new row — so a daemon that crashes + restarts reclaims its
|
|
347
|
+
// previous id (and thus its owned issues) rather than leaving them stuck
|
|
348
|
+
// until releaseDeadOwners runs.
|
|
349
|
+
|
|
350
|
+
/** Register this daemon, adopting an orphan slot if available. */
|
|
351
|
+
async registerDaemon(
|
|
352
|
+
displayName: string,
|
|
353
|
+
endpoint: string,
|
|
354
|
+
capacity: number,
|
|
355
|
+
leaseTtlMs: number,
|
|
356
|
+
): Promise<number> {
|
|
357
|
+
const db = getDB();
|
|
358
|
+
const cutoff = new Date(Date.now() - leaseTtlMs).toISOString();
|
|
359
|
+
|
|
360
|
+
const orphan = await db.get<{ id: number }>(
|
|
361
|
+
"SELECT id FROM {{daemons}} WHERE last_heartbeat < ? ORDER BY last_heartbeat LIMIT 1",
|
|
362
|
+
[cutoff]
|
|
363
|
+
);
|
|
364
|
+
if (orphan) {
|
|
365
|
+
const now = new Date().toISOString();
|
|
366
|
+
const res = await db.run(
|
|
367
|
+
"UPDATE {{daemons}} SET display_name = ?, internal_endpoint = ?, last_heartbeat = ?, status = 'active' WHERE id = ? AND last_heartbeat < ?",
|
|
368
|
+
[displayName, endpoint, now, orphan.id, cutoff]
|
|
369
|
+
);
|
|
370
|
+
if (res.changes === 1) return orphan.id;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const now = new Date().toISOString();
|
|
374
|
+
const ins = await db.run(
|
|
375
|
+
"INSERT INTO {{daemons}} (display_name, internal_endpoint, capacity, last_heartbeat, registered_at, status) VALUES (?, ?, ?, ?, ?, 'active')",
|
|
376
|
+
[displayName, endpoint, capacity, now, now]
|
|
377
|
+
);
|
|
378
|
+
return ins.insertId;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async heartbeat(daemonId: number): Promise<void> {
|
|
382
|
+
await getDB().run(
|
|
383
|
+
"UPDATE {{daemons}} SET last_heartbeat = ? WHERE id = ?",
|
|
384
|
+
[new Date().toISOString(), daemonId]
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async markDaemonStatus(daemonId: number, status: "active" | "drained" | "dead"): Promise<void> {
|
|
389
|
+
await getDB().run(
|
|
390
|
+
"UPDATE {{daemons}} SET status = ? WHERE id = ?",
|
|
391
|
+
[status, daemonId]
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Clear owner_daemon_id on issues whose daemon has missed the lease. */
|
|
396
|
+
async releaseDeadOwners(leaseTtlMs: number): Promise<number> {
|
|
397
|
+
const db = getDB();
|
|
398
|
+
const cutoff = new Date(Date.now() - leaseTtlMs).toISOString();
|
|
399
|
+
const res = await db.run(
|
|
400
|
+
"UPDATE {{issues}} SET owner_daemon_id = NULL WHERE owner_daemon_id IN (SELECT id FROM {{daemons}} WHERE last_heartbeat < ?)",
|
|
401
|
+
[cutoff]
|
|
402
|
+
);
|
|
403
|
+
return res.changes;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Atomic claim: affected_rows decides the winner. Returns true iff this
|
|
408
|
+
* daemon won the race. Re-claiming an issue you already own also returns
|
|
409
|
+
* false (the WHERE requires owner IS NULL) — call sites check ownership
|
|
410
|
+
* first when they need to handle the "already mine" case.
|
|
411
|
+
*/
|
|
412
|
+
async claimIssue(issueId: string, daemonId: number): Promise<boolean> {
|
|
413
|
+
const res = await getDB().run(
|
|
414
|
+
"UPDATE {{issues}} SET owner_daemon_id = ? WHERE id = ? AND owner_daemon_id IS NULL",
|
|
415
|
+
[daemonId, issueId]
|
|
416
|
+
);
|
|
417
|
+
return res.changes === 1;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** First-boot migration: claim all pre-existing ownerless issues. */
|
|
421
|
+
async claimAllOwnerless(daemonId: number): Promise<number> {
|
|
422
|
+
const res = await getDB().run(
|
|
423
|
+
"UPDATE {{issues}} SET owner_daemon_id = ? WHERE owner_daemon_id IS NULL",
|
|
424
|
+
[daemonId]
|
|
425
|
+
);
|
|
426
|
+
return res.changes;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Atomic message claim: pending → running. False = lost or already done. */
|
|
430
|
+
async claimMessage(messageId: string): Promise<boolean> {
|
|
431
|
+
const res = await getDB().run(
|
|
432
|
+
"UPDATE {{messages}} SET status = 'running', updated_at = ? WHERE id = ? AND status = 'pending'",
|
|
433
|
+
[new Date().toISOString(), messageId]
|
|
434
|
+
);
|
|
435
|
+
return res.changes === 1;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async listOwnedIssues(daemonId: number): Promise<Issue[]> {
|
|
439
|
+
const rows = await getDB().all<IssueRow>(
|
|
440
|
+
"SELECT * FROM {{issues}} WHERE owner_daemon_id = ?",
|
|
441
|
+
[daemonId]
|
|
442
|
+
);
|
|
443
|
+
return rows.map(rowToIssue);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async listOwnedSessions(daemonId: number): Promise<OpSession[]> {
|
|
447
|
+
const rows = await getDB().all<SessionRow>(
|
|
448
|
+
`SELECT s.* FROM {{op_sessions}} s
|
|
449
|
+
INNER JOIN {{issues}} i ON i.id = s.issue_id
|
|
450
|
+
WHERE i.owner_daemon_id = ?
|
|
451
|
+
ORDER BY s.created_at`,
|
|
452
|
+
[daemonId]
|
|
453
|
+
);
|
|
454
|
+
return rows.map(rowToSession);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Scoped variant of getPendingOrRunningMessages: only this daemon's issues. */
|
|
458
|
+
async getOwnedPendingOrRunningMessages(daemonId: number): Promise<Message[]> {
|
|
459
|
+
const rows = await getDB().all<MessageRow>(
|
|
460
|
+
`SELECT m.* FROM {{messages}} m
|
|
461
|
+
INNER JOIN {{op_sessions}} s ON s.id = m.session_id
|
|
462
|
+
INNER JOIN {{issues}} i ON i.id = s.issue_id
|
|
463
|
+
WHERE i.owner_daemon_id = ? AND m.status IN ('pending', 'running')
|
|
464
|
+
ORDER BY m.created_at ASC`,
|
|
465
|
+
[daemonId]
|
|
466
|
+
);
|
|
467
|
+
return rows.map(rowToMessage);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async close(): Promise<void> {
|
|
471
|
+
// The DB singleton is owned by db.ts; callers close it via shutdown of the
|
|
472
|
+
// driver there. This method is retained for API compatibility (tests,
|
|
473
|
+
// index.ts shutdown) and is a no-op now.
|
|
474
|
+
log.debug("store.close() is a no-op; DB lifecycle is managed by db.ts");
|
|
486
475
|
}
|
|
487
476
|
}
|