@owloops/browserbird 1.0.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/LICENSE +106 -0
- package/README.md +329 -0
- package/bin/browserbird +11 -0
- package/package.json +68 -0
- package/src/channel/blocks.ts +485 -0
- package/src/channel/coalesce.ts +79 -0
- package/src/channel/commands.ts +216 -0
- package/src/channel/handler.ts +272 -0
- package/src/channel/slack.ts +573 -0
- package/src/channel/types.ts +59 -0
- package/src/cli/banner.ts +10 -0
- package/src/cli/birds.ts +396 -0
- package/src/cli/config.ts +77 -0
- package/src/cli/doctor.ts +63 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/jobs.ts +166 -0
- package/src/cli/logs.ts +67 -0
- package/src/cli/run.ts +148 -0
- package/src/cli/sessions.ts +158 -0
- package/src/cli/style.ts +19 -0
- package/src/config.ts +291 -0
- package/src/core/logger.ts +78 -0
- package/src/core/redact.ts +75 -0
- package/src/core/types.ts +83 -0
- package/src/core/uid.ts +26 -0
- package/src/core/utils.ts +137 -0
- package/src/cron/parse.ts +146 -0
- package/src/cron/scheduler.ts +242 -0
- package/src/daemon.ts +169 -0
- package/src/db/auth.ts +49 -0
- package/src/db/birds.ts +357 -0
- package/src/db/core.ts +377 -0
- package/src/db/index.ts +10 -0
- package/src/db/jobs.ts +289 -0
- package/src/db/logs.ts +64 -0
- package/src/db/messages.ts +79 -0
- package/src/db/path.ts +30 -0
- package/src/db/sessions.ts +165 -0
- package/src/jobs.ts +140 -0
- package/src/provider/claude.test.ts +95 -0
- package/src/provider/claude.ts +196 -0
- package/src/provider/opencode.test.ts +169 -0
- package/src/provider/opencode.ts +248 -0
- package/src/provider/session.ts +65 -0
- package/src/provider/spawn.ts +173 -0
- package/src/provider/stream.ts +67 -0
- package/src/provider/types.ts +24 -0
- package/src/server/auth.ts +135 -0
- package/src/server/health.ts +87 -0
- package/src/server/http.ts +132 -0
- package/src/server/index.ts +6 -0
- package/src/server/lifecycle.ts +135 -0
- package/src/server/routes.ts +1199 -0
- package/src/server/sse.ts +54 -0
- package/src/server/static.ts +45 -0
- package/src/server/vnc-proxy.ts +75 -0
- package/web/dist/assets/index-C6MBAUmO.js +7 -0
- package/web/dist/assets/index-JMPJCJ2F.css +1 -0
- package/web/dist/favicon.svg +5 -0
- package/web/dist/index.html +20 -0
- package/web/dist/logo-icon.png +0 -0
- package/web/dist/logo-icon.svg +5 -0
- package/web/dist/logo.svg +7 -0
package/src/db/core.ts
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/** @fileoverview SQLite database lifecycle, migrations, and query utilities. */
|
|
2
|
+
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { mkdirSync } from 'node:fs';
|
|
5
|
+
import { dirname } from 'node:path';
|
|
6
|
+
import { logger } from '../core/logger.ts';
|
|
7
|
+
|
|
8
|
+
export interface PaginatedResult<T> {
|
|
9
|
+
items: T[];
|
|
10
|
+
page: number;
|
|
11
|
+
perPage: number;
|
|
12
|
+
totalItems: number;
|
|
13
|
+
totalPages: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PaginateOptions {
|
|
17
|
+
where?: string;
|
|
18
|
+
params?: (string | number)[];
|
|
19
|
+
defaultSort?: string;
|
|
20
|
+
sort?: string;
|
|
21
|
+
search?: string;
|
|
22
|
+
allowedSortColumns?: ReadonlySet<string>;
|
|
23
|
+
searchColumns?: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_PER_PAGE = 15;
|
|
27
|
+
export const MAX_PER_PAGE = 100;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parses a sort string into an SQL ORDER BY clause.
|
|
31
|
+
* Each token is a column name optionally prefixed with `-` for DESC.
|
|
32
|
+
* Only columns present in `allowedColumns` are included.
|
|
33
|
+
*/
|
|
34
|
+
export function parseSort(
|
|
35
|
+
raw: string | undefined,
|
|
36
|
+
allowedColumns: ReadonlySet<string>,
|
|
37
|
+
fallback: string,
|
|
38
|
+
): string {
|
|
39
|
+
if (!raw) return fallback;
|
|
40
|
+
const parts: string[] = [];
|
|
41
|
+
for (const token of raw.split(',')) {
|
|
42
|
+
const trimmed = token.trim();
|
|
43
|
+
if (!trimmed) continue;
|
|
44
|
+
const desc = trimmed.startsWith('-');
|
|
45
|
+
const col = desc ? trimmed.slice(1) : trimmed;
|
|
46
|
+
if (allowedColumns.has(col)) {
|
|
47
|
+
parts.push(`${col} ${desc ? 'DESC' : 'ASC'}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return parts.length > 0 ? parts.join(', ') : fallback;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Builds a parenthesized OR clause for LIKE-based search across columns.
|
|
55
|
+
* Returns empty sql/params when the search term is empty.
|
|
56
|
+
*/
|
|
57
|
+
export function buildSearchClause(
|
|
58
|
+
term: string | undefined,
|
|
59
|
+
columns: readonly string[],
|
|
60
|
+
): { sql: string; params: string[] } {
|
|
61
|
+
if (!term || columns.length === 0) return { sql: '', params: [] };
|
|
62
|
+
const like = `%${term}%`;
|
|
63
|
+
const sql = `(${columns.map((c) => `${c} LIKE ?`).join(' OR ')})`;
|
|
64
|
+
const params = columns.map(() => like);
|
|
65
|
+
return { sql, params };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function paginate<T>(
|
|
69
|
+
table: string,
|
|
70
|
+
page: number,
|
|
71
|
+
perPage: number,
|
|
72
|
+
whereOrOptions?: string | PaginateOptions,
|
|
73
|
+
params?: (string | number)[],
|
|
74
|
+
orderBy?: string,
|
|
75
|
+
): PaginatedResult<T> {
|
|
76
|
+
let where: string;
|
|
77
|
+
let allParams: (string | number)[];
|
|
78
|
+
let resolvedOrderBy: string;
|
|
79
|
+
|
|
80
|
+
if (typeof whereOrOptions === 'object' && whereOrOptions !== null) {
|
|
81
|
+
const opts = whereOrOptions;
|
|
82
|
+
const conditions: string[] = [];
|
|
83
|
+
allParams = [...(opts.params ?? [])];
|
|
84
|
+
|
|
85
|
+
if (opts.where) conditions.push(opts.where);
|
|
86
|
+
|
|
87
|
+
if (opts.search && opts.searchColumns && opts.searchColumns.length > 0) {
|
|
88
|
+
const sc = buildSearchClause(opts.search, opts.searchColumns);
|
|
89
|
+
if (sc.sql) {
|
|
90
|
+
conditions.push(sc.sql);
|
|
91
|
+
allParams.push(...sc.params);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
where = conditions.join(' AND ');
|
|
96
|
+
resolvedOrderBy = parseSort(
|
|
97
|
+
opts.sort,
|
|
98
|
+
opts.allowedSortColumns ?? new Set<string>(),
|
|
99
|
+
opts.defaultSort ?? 'created_at DESC',
|
|
100
|
+
);
|
|
101
|
+
} else {
|
|
102
|
+
where = whereOrOptions ?? '';
|
|
103
|
+
allParams = params ?? [];
|
|
104
|
+
resolvedOrderBy = orderBy ?? 'created_at DESC';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const pp = Math.min(Math.max(perPage, 1), MAX_PER_PAGE);
|
|
108
|
+
const p = Math.max(page, 1);
|
|
109
|
+
const offset = (p - 1) * pp;
|
|
110
|
+
|
|
111
|
+
const countSql = `SELECT COUNT(*) as count FROM ${table}${where ? ` WHERE ${where}` : ''}`;
|
|
112
|
+
const countRow = getDb()
|
|
113
|
+
.prepare(countSql)
|
|
114
|
+
.get(...allParams) as unknown as { count: number };
|
|
115
|
+
const totalItems = countRow.count;
|
|
116
|
+
const totalPages = Math.max(Math.ceil(totalItems / pp), 1);
|
|
117
|
+
|
|
118
|
+
const dataSql = `SELECT * FROM ${table}${where ? ` WHERE ${where}` : ''} ORDER BY ${resolvedOrderBy} LIMIT ? OFFSET ?`;
|
|
119
|
+
const items = getDb()
|
|
120
|
+
.prepare(dataSql)
|
|
121
|
+
.all(...allParams, pp, offset) as unknown as T[];
|
|
122
|
+
|
|
123
|
+
return { items, page: p, perPage: pp, totalItems, totalPages };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface Migration {
|
|
127
|
+
name: string;
|
|
128
|
+
up: (db: DatabaseSync) => void;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Versioned migration registry. Each entry runs once, in order.
|
|
133
|
+
* PRAGMA user_version tracks which migrations have been applied.
|
|
134
|
+
* All DDL uses IF NOT EXISTS for idempotency.
|
|
135
|
+
*/
|
|
136
|
+
const MIGRATIONS: Migration[] = [
|
|
137
|
+
{
|
|
138
|
+
name: 'initial schema',
|
|
139
|
+
up(d) {
|
|
140
|
+
d.exec(`
|
|
141
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
142
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
143
|
+
email TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
|
144
|
+
password_hash TEXT NOT NULL,
|
|
145
|
+
token_key TEXT NOT NULL,
|
|
146
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
150
|
+
key TEXT PRIMARY KEY,
|
|
151
|
+
value TEXT NOT NULL
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
155
|
+
uid TEXT PRIMARY KEY,
|
|
156
|
+
channel_id TEXT NOT NULL,
|
|
157
|
+
thread_id TEXT,
|
|
158
|
+
agent_id TEXT NOT NULL DEFAULT 'default',
|
|
159
|
+
provider_session_id TEXT NOT NULL,
|
|
160
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
161
|
+
last_active TEXT NOT NULL DEFAULT (datetime('now')),
|
|
162
|
+
message_count INTEGER NOT NULL DEFAULT 0,
|
|
163
|
+
UNIQUE(channel_id, thread_id)
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
CREATE TABLE IF NOT EXISTS cron_jobs (
|
|
167
|
+
uid TEXT PRIMARY KEY,
|
|
168
|
+
name TEXT NOT NULL,
|
|
169
|
+
agent_id TEXT NOT NULL DEFAULT 'default',
|
|
170
|
+
schedule TEXT NOT NULL,
|
|
171
|
+
prompt TEXT NOT NULL,
|
|
172
|
+
target_channel_id TEXT,
|
|
173
|
+
active_hours_start TEXT,
|
|
174
|
+
active_hours_end TEXT,
|
|
175
|
+
timezone TEXT DEFAULT 'UTC',
|
|
176
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
177
|
+
failure_count INTEGER NOT NULL DEFAULT 0,
|
|
178
|
+
last_run TEXT,
|
|
179
|
+
last_status TEXT,
|
|
180
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
CREATE TABLE IF NOT EXISTS cron_runs (
|
|
184
|
+
uid TEXT PRIMARY KEY,
|
|
185
|
+
job_uid TEXT NOT NULL REFERENCES cron_jobs(uid),
|
|
186
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
187
|
+
finished_at TEXT,
|
|
188
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
189
|
+
result TEXT,
|
|
190
|
+
error TEXT
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
194
|
+
id INTEGER PRIMARY KEY,
|
|
195
|
+
channel_id TEXT NOT NULL,
|
|
196
|
+
thread_id TEXT,
|
|
197
|
+
user_id TEXT NOT NULL,
|
|
198
|
+
direction TEXT NOT NULL CHECK(direction IN ('in', 'out')),
|
|
199
|
+
content TEXT,
|
|
200
|
+
tokens_in INTEGER,
|
|
201
|
+
tokens_out INTEGER,
|
|
202
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
206
|
+
id INTEGER PRIMARY KEY,
|
|
207
|
+
name TEXT NOT NULL,
|
|
208
|
+
payload TEXT,
|
|
209
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'running', 'completed', 'failed')),
|
|
210
|
+
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low')),
|
|
211
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
212
|
+
max_attempts INTEGER NOT NULL DEFAULT 1,
|
|
213
|
+
timeout INTEGER NOT NULL DEFAULT 1800,
|
|
214
|
+
cron_job_uid TEXT REFERENCES cron_jobs(uid),
|
|
215
|
+
run_at TEXT,
|
|
216
|
+
started_at TEXT,
|
|
217
|
+
completed_at TEXT,
|
|
218
|
+
result TEXT,
|
|
219
|
+
error TEXT,
|
|
220
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
CREATE TABLE IF NOT EXISTS logs (
|
|
224
|
+
id INTEGER PRIMARY KEY,
|
|
225
|
+
level TEXT NOT NULL DEFAULT 'info' CHECK(level IN ('debug', 'info', 'warn', 'error')),
|
|
226
|
+
source TEXT NOT NULL,
|
|
227
|
+
message TEXT NOT NULL,
|
|
228
|
+
channel_id TEXT,
|
|
229
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_channel_thread
|
|
233
|
+
ON sessions(channel_id, thread_id);
|
|
234
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_last_active
|
|
235
|
+
ON sessions(last_active);
|
|
236
|
+
CREATE INDEX IF NOT EXISTS idx_messages_channel_thread
|
|
237
|
+
ON messages(channel_id, thread_id);
|
|
238
|
+
CREATE INDEX IF NOT EXISTS idx_messages_created_at
|
|
239
|
+
ON messages(created_at DESC);
|
|
240
|
+
CREATE INDEX IF NOT EXISTS idx_cron_runs_job_uid
|
|
241
|
+
ON cron_runs(job_uid, started_at DESC);
|
|
242
|
+
CREATE INDEX IF NOT EXISTS idx_cron_jobs_enabled
|
|
243
|
+
ON cron_jobs(enabled);
|
|
244
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_poll
|
|
245
|
+
ON jobs(status, priority, run_at, created_at);
|
|
246
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_cron_job_uid
|
|
247
|
+
ON jobs(cron_job_uid);
|
|
248
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_stale
|
|
249
|
+
ON jobs(status, started_at);
|
|
250
|
+
CREATE INDEX IF NOT EXISTS idx_logs_created_at
|
|
251
|
+
ON logs(created_at DESC);
|
|
252
|
+
CREATE INDEX IF NOT EXISTS idx_logs_level_source
|
|
253
|
+
ON logs(level, source, created_at DESC);
|
|
254
|
+
`);
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
let db: DatabaseSync | null = null;
|
|
260
|
+
|
|
261
|
+
function getSchemaVersion(d: DatabaseSync): number {
|
|
262
|
+
const row = d.prepare('PRAGMA user_version').get() as unknown as { user_version: number };
|
|
263
|
+
return row.user_version;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function setSchemaVersion(d: DatabaseSync, version: number): void {
|
|
267
|
+
d.exec(`PRAGMA user_version = ${version}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Runs pending migrations inside a transaction.
|
|
272
|
+
* Safe to call on every startup; already-applied migrations are skipped.
|
|
273
|
+
*/
|
|
274
|
+
function migrate(d: DatabaseSync): void {
|
|
275
|
+
const current = getSchemaVersion(d);
|
|
276
|
+
const target = MIGRATIONS.length;
|
|
277
|
+
|
|
278
|
+
if (current >= target) return;
|
|
279
|
+
|
|
280
|
+
for (let i = current; i < target; i++) {
|
|
281
|
+
const migration = MIGRATIONS[i]!;
|
|
282
|
+
logger.info(`migration ${i + 1}/${target}: ${migration.name}`);
|
|
283
|
+
d.exec('BEGIN');
|
|
284
|
+
try {
|
|
285
|
+
migration.up(d);
|
|
286
|
+
setSchemaVersion(d, i + 1);
|
|
287
|
+
d.exec('COMMIT');
|
|
288
|
+
} catch (err) {
|
|
289
|
+
d.exec('ROLLBACK');
|
|
290
|
+
throw new Error(
|
|
291
|
+
`migration "${migration.name}" failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
292
|
+
{ cause: err },
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Opens (or creates) the SQLite database at the given path.
|
|
300
|
+
* Configures WAL mode, runs pending migrations.
|
|
301
|
+
*/
|
|
302
|
+
export function openDatabase(dbPath: string): void {
|
|
303
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
304
|
+
|
|
305
|
+
db = new DatabaseSync(dbPath);
|
|
306
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
307
|
+
db.exec('PRAGMA synchronous = NORMAL');
|
|
308
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
309
|
+
db.exec('PRAGMA busy_timeout = 5000');
|
|
310
|
+
migrate(db);
|
|
311
|
+
logger.info(`database opened at ${dbPath}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function closeDatabase(): void {
|
|
315
|
+
if (db) {
|
|
316
|
+
db.close();
|
|
317
|
+
db = null;
|
|
318
|
+
logger.info('database closed');
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function getDb(): DatabaseSync {
|
|
323
|
+
if (!db) {
|
|
324
|
+
throw new Error('Database not initialized. Call openDatabase() first.');
|
|
325
|
+
}
|
|
326
|
+
return db;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Runs WAL checkpoint and query planner optimization. Safe to call periodically. */
|
|
330
|
+
export function optimizeDatabase(): void {
|
|
331
|
+
const d = getDb();
|
|
332
|
+
d.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
333
|
+
d.exec('PRAGMA optimize');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Resolves a row by UID or UID prefix, like git's short SHA resolution.
|
|
338
|
+
* Exact match first (fast path), then prefix scan via LIKE.
|
|
339
|
+
*/
|
|
340
|
+
export function resolveByUid<T>(
|
|
341
|
+
table: string,
|
|
342
|
+
uidPrefix: string,
|
|
343
|
+
): { row: T } | { ambiguous: true; count: number } | undefined {
|
|
344
|
+
const input = uidPrefix.toLowerCase();
|
|
345
|
+
const d = getDb();
|
|
346
|
+
|
|
347
|
+
const exact = d.prepare(`SELECT * FROM ${table} WHERE uid = ?`).get(input) as unknown as
|
|
348
|
+
| T
|
|
349
|
+
| undefined;
|
|
350
|
+
if (exact) return { row: exact };
|
|
351
|
+
|
|
352
|
+
const rows = d
|
|
353
|
+
.prepare(`SELECT * FROM ${table} WHERE uid LIKE ? LIMIT 2`)
|
|
354
|
+
.all(`${input}%`) as unknown as T[];
|
|
355
|
+
if (rows.length === 0) return undefined;
|
|
356
|
+
if (rows.length > 1) {
|
|
357
|
+
const countRow = d
|
|
358
|
+
.prepare(`SELECT COUNT(*) as count FROM ${table} WHERE uid LIKE ?`)
|
|
359
|
+
.get(`${input}%`) as unknown as { count: number };
|
|
360
|
+
return { ambiguous: true, count: countRow.count };
|
|
361
|
+
}
|
|
362
|
+
return { row: rows[0]! };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Wraps a function in BEGIN/COMMIT with automatic ROLLBACK on error. */
|
|
366
|
+
export function transaction<T>(fn: () => T): T {
|
|
367
|
+
const d = getDb();
|
|
368
|
+
d.exec('BEGIN IMMEDIATE');
|
|
369
|
+
try {
|
|
370
|
+
const result = fn();
|
|
371
|
+
d.exec('COMMIT');
|
|
372
|
+
return result;
|
|
373
|
+
} catch (err) {
|
|
374
|
+
d.exec('ROLLBACK');
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
377
|
+
}
|
package/src/db/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** @fileoverview Barrel re-export: keeps all existing import sites working. */
|
|
2
|
+
|
|
3
|
+
export * from './core.ts';
|
|
4
|
+
export * from './sessions.ts';
|
|
5
|
+
export * from './birds.ts';
|
|
6
|
+
export * from './jobs.ts';
|
|
7
|
+
export * from './messages.ts';
|
|
8
|
+
export * from './logs.ts';
|
|
9
|
+
export * from './auth.ts';
|
|
10
|
+
export * from './path.ts';
|
package/src/db/jobs.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/** @fileoverview Work queue: persistent job scheduling and processing. */
|
|
2
|
+
|
|
3
|
+
import type { PaginatedResult } from './core.ts';
|
|
4
|
+
import { getDb, paginate, transaction, DEFAULT_PER_PAGE } from './core.ts';
|
|
5
|
+
|
|
6
|
+
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed';
|
|
7
|
+
export type JobPriority = 'high' | 'normal' | 'low';
|
|
8
|
+
|
|
9
|
+
export interface JobRow {
|
|
10
|
+
id: number;
|
|
11
|
+
name: string;
|
|
12
|
+
payload: string | null;
|
|
13
|
+
status: JobStatus;
|
|
14
|
+
priority: JobPriority;
|
|
15
|
+
attempts: number;
|
|
16
|
+
max_attempts: number;
|
|
17
|
+
timeout: number;
|
|
18
|
+
cron_job_uid: string | null;
|
|
19
|
+
run_at: string | null;
|
|
20
|
+
started_at: string | null;
|
|
21
|
+
completed_at: string | null;
|
|
22
|
+
result: string | null;
|
|
23
|
+
error: string | null;
|
|
24
|
+
created_at: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CreateJobOptions {
|
|
28
|
+
name: string;
|
|
29
|
+
payload?: unknown;
|
|
30
|
+
priority?: JobPriority;
|
|
31
|
+
maxAttempts?: number;
|
|
32
|
+
timeout?: number;
|
|
33
|
+
delaySeconds?: number;
|
|
34
|
+
cronJobUid?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ListJobsFilters {
|
|
38
|
+
status?: string;
|
|
39
|
+
cronJobUid?: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface JobStats {
|
|
44
|
+
pending: number;
|
|
45
|
+
running: number;
|
|
46
|
+
completed: number;
|
|
47
|
+
failed: number;
|
|
48
|
+
total: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createJob(options: CreateJobOptions): JobRow {
|
|
52
|
+
const payload = options.payload != null ? JSON.stringify(options.payload) : null;
|
|
53
|
+
const priority = options.priority ?? 'normal';
|
|
54
|
+
const maxAttempts = options.maxAttempts ?? 1;
|
|
55
|
+
const timeout = options.timeout ?? 1800;
|
|
56
|
+
const cronJobUid = options.cronJobUid ?? null;
|
|
57
|
+
|
|
58
|
+
if (options.delaySeconds) {
|
|
59
|
+
return getDb()
|
|
60
|
+
.prepare(
|
|
61
|
+
`INSERT INTO jobs (name, payload, priority, max_attempts, timeout, cron_job_uid, run_at)
|
|
62
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now', '+' || ? || ' seconds'))
|
|
63
|
+
RETURNING *`,
|
|
64
|
+
)
|
|
65
|
+
.get(
|
|
66
|
+
options.name,
|
|
67
|
+
payload,
|
|
68
|
+
priority,
|
|
69
|
+
maxAttempts,
|
|
70
|
+
timeout,
|
|
71
|
+
cronJobUid,
|
|
72
|
+
options.delaySeconds,
|
|
73
|
+
) as unknown as JobRow;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return getDb()
|
|
77
|
+
.prepare(
|
|
78
|
+
`INSERT INTO jobs (name, payload, priority, max_attempts, timeout, cron_job_uid)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
80
|
+
RETURNING *`,
|
|
81
|
+
)
|
|
82
|
+
.get(options.name, payload, priority, maxAttempts, timeout, cronJobUid) as unknown as JobRow;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Atomically claims the next pending job for processing.
|
|
87
|
+
* Uses IMMEDIATE transaction to prevent race conditions.
|
|
88
|
+
* Priority order: high > normal > low, then by creation time.
|
|
89
|
+
*/
|
|
90
|
+
export function claimNextJob(): JobRow | undefined {
|
|
91
|
+
return transaction(() => {
|
|
92
|
+
const priorityOrder = `CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 WHEN 'low' THEN 2 END`;
|
|
93
|
+
const row = getDb()
|
|
94
|
+
.prepare(
|
|
95
|
+
`SELECT * FROM jobs
|
|
96
|
+
WHERE status = 'pending' AND (run_at IS NULL OR run_at <= datetime('now'))
|
|
97
|
+
ORDER BY ${priorityOrder}, created_at ASC
|
|
98
|
+
LIMIT 1`,
|
|
99
|
+
)
|
|
100
|
+
.get() as unknown as JobRow | undefined;
|
|
101
|
+
|
|
102
|
+
if (!row) return undefined;
|
|
103
|
+
|
|
104
|
+
getDb()
|
|
105
|
+
.prepare(
|
|
106
|
+
`UPDATE jobs SET status = 'running', started_at = datetime('now'), attempts = attempts + 1
|
|
107
|
+
WHERE id = ?`,
|
|
108
|
+
)
|
|
109
|
+
.run(row.id);
|
|
110
|
+
|
|
111
|
+
return { ...row, status: 'running' as const, attempts: row.attempts + 1 };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function completeJob(jobId: number, result?: string): void {
|
|
116
|
+
getDb()
|
|
117
|
+
.prepare(
|
|
118
|
+
`UPDATE jobs SET status = 'completed', completed_at = datetime('now'), result = ?
|
|
119
|
+
WHERE id = ?`,
|
|
120
|
+
)
|
|
121
|
+
.run(result ?? null, jobId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function failJob(jobId: number, error: string): void {
|
|
125
|
+
const job = getDb()
|
|
126
|
+
.prepare('SELECT attempts, max_attempts FROM jobs WHERE id = ?')
|
|
127
|
+
.get(jobId) as unknown as { attempts: number; max_attempts: number } | undefined;
|
|
128
|
+
|
|
129
|
+
if (!job) return;
|
|
130
|
+
|
|
131
|
+
if (job.attempts < job.max_attempts) {
|
|
132
|
+
const delaySeconds = job.attempts * job.attempts;
|
|
133
|
+
getDb()
|
|
134
|
+
.prepare(
|
|
135
|
+
`UPDATE jobs SET status = 'pending', error = ?,
|
|
136
|
+
run_at = datetime('now', '+' || ? || ' seconds')
|
|
137
|
+
WHERE id = ?`,
|
|
138
|
+
)
|
|
139
|
+
.run(error, delaySeconds, jobId);
|
|
140
|
+
} else {
|
|
141
|
+
getDb()
|
|
142
|
+
.prepare(
|
|
143
|
+
`UPDATE jobs SET status = 'failed', completed_at = datetime('now'), error = ?
|
|
144
|
+
WHERE id = ?`,
|
|
145
|
+
)
|
|
146
|
+
.run(error, jobId);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Marks running jobs past their timeout as failed and cascades to linked cron_runs/cron_jobs. */
|
|
151
|
+
export function failStaleJobs(): number {
|
|
152
|
+
const d = getDb();
|
|
153
|
+
|
|
154
|
+
const staleRows = d
|
|
155
|
+
.prepare(
|
|
156
|
+
`SELECT id, cron_job_uid FROM jobs
|
|
157
|
+
WHERE status = 'running'
|
|
158
|
+
AND started_at < datetime('now', '-' || timeout || ' seconds')`,
|
|
159
|
+
)
|
|
160
|
+
.all() as unknown as Array<{ id: number; cron_job_uid: string | null }>;
|
|
161
|
+
|
|
162
|
+
if (staleRows.length === 0) return 0;
|
|
163
|
+
|
|
164
|
+
const updateJob = d.prepare(
|
|
165
|
+
`UPDATE jobs SET status = 'failed', error = 'timeout', completed_at = datetime('now')
|
|
166
|
+
WHERE id = ?`,
|
|
167
|
+
);
|
|
168
|
+
const updateRun = d.prepare(
|
|
169
|
+
`UPDATE cron_runs SET status = 'error', error = 'timeout', finished_at = datetime('now')
|
|
170
|
+
WHERE job_uid = ? AND status = 'running'`,
|
|
171
|
+
);
|
|
172
|
+
const updateBird = d.prepare(
|
|
173
|
+
`UPDATE cron_jobs SET last_status = 'failed', failure_count = failure_count + 1
|
|
174
|
+
WHERE uid = ?`,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
for (const row of staleRows) {
|
|
178
|
+
updateJob.run(row.id);
|
|
179
|
+
if (row.cron_job_uid != null) {
|
|
180
|
+
updateRun.run(row.cron_job_uid);
|
|
181
|
+
updateBird.run(row.cron_job_uid);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return staleRows.length;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function deleteOldJobs(retentionDays: number): number {
|
|
189
|
+
const stmt = getDb().prepare(
|
|
190
|
+
`DELETE FROM jobs WHERE status IN ('completed', 'failed')
|
|
191
|
+
AND completed_at < datetime('now', ? || ' days')`,
|
|
192
|
+
);
|
|
193
|
+
return Number(stmt.run(`-${retentionDays}`).changes);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const JOB_SORT_COLUMNS = new Set(['id', 'name', 'status', 'priority', 'created_at', 'started_at']);
|
|
197
|
+
const JOB_SEARCH_COLUMNS = ['name', 'error'] as const;
|
|
198
|
+
|
|
199
|
+
export function listJobs(
|
|
200
|
+
page = 1,
|
|
201
|
+
perPage = DEFAULT_PER_PAGE,
|
|
202
|
+
filters: ListJobsFilters = {},
|
|
203
|
+
sort?: string,
|
|
204
|
+
search?: string,
|
|
205
|
+
): PaginatedResult<JobRow> {
|
|
206
|
+
const conditions: string[] = [];
|
|
207
|
+
const params: (string | number)[] = [];
|
|
208
|
+
|
|
209
|
+
if (filters.status) {
|
|
210
|
+
conditions.push('status = ?');
|
|
211
|
+
params.push(filters.status);
|
|
212
|
+
}
|
|
213
|
+
if (filters.cronJobUid != null) {
|
|
214
|
+
conditions.push('cron_job_uid = ?');
|
|
215
|
+
params.push(filters.cronJobUid);
|
|
216
|
+
}
|
|
217
|
+
if (filters.name) {
|
|
218
|
+
conditions.push('name LIKE ?');
|
|
219
|
+
params.push(`%${filters.name}%`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const where = conditions.join(' AND ');
|
|
223
|
+
return paginate<JobRow>('jobs', page, perPage, {
|
|
224
|
+
where,
|
|
225
|
+
params,
|
|
226
|
+
defaultSort: 'created_at DESC',
|
|
227
|
+
sort,
|
|
228
|
+
search,
|
|
229
|
+
allowedSortColumns: JOB_SORT_COLUMNS,
|
|
230
|
+
searchColumns: JOB_SEARCH_COLUMNS,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function getJobStats(): JobStats {
|
|
235
|
+
const rows = getDb()
|
|
236
|
+
.prepare('SELECT status, COUNT(*) as count FROM jobs GROUP BY status')
|
|
237
|
+
.all() as unknown as Array<{ status: string; count: number }>;
|
|
238
|
+
|
|
239
|
+
const stats: JobStats = { pending: 0, running: 0, completed: 0, failed: 0, total: 0 };
|
|
240
|
+
for (const row of rows) {
|
|
241
|
+
if (row.status in stats) {
|
|
242
|
+
(stats as unknown as Record<string, number>)[row.status] = row.count;
|
|
243
|
+
}
|
|
244
|
+
stats.total += row.count;
|
|
245
|
+
}
|
|
246
|
+
return stats;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function retryJob(jobId: number): boolean {
|
|
250
|
+
const result = getDb()
|
|
251
|
+
.prepare(
|
|
252
|
+
`UPDATE jobs SET status = 'pending', attempts = 0, error = NULL, result = NULL,
|
|
253
|
+
run_at = NULL, started_at = NULL, completed_at = NULL
|
|
254
|
+
WHERE id = ? AND status = 'failed'`,
|
|
255
|
+
)
|
|
256
|
+
.run(jobId);
|
|
257
|
+
return Number(result.changes) > 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function retryAllFailedJobs(): number {
|
|
261
|
+
const result = getDb()
|
|
262
|
+
.prepare(
|
|
263
|
+
`UPDATE jobs SET status = 'pending', attempts = 0, error = NULL, result = NULL,
|
|
264
|
+
run_at = NULL, started_at = NULL, completed_at = NULL
|
|
265
|
+
WHERE status = 'failed'`,
|
|
266
|
+
)
|
|
267
|
+
.run();
|
|
268
|
+
return Number(result.changes);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function deleteJob(jobId: number): boolean {
|
|
272
|
+
const result = getDb().prepare('DELETE FROM jobs WHERE id = ?').run(jobId);
|
|
273
|
+
return Number(result.changes) > 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function clearJobs(status: 'completed' | 'failed'): number {
|
|
277
|
+
const result = getDb().prepare('DELETE FROM jobs WHERE status = ?').run(status);
|
|
278
|
+
return Number(result.changes);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Returns true if the given cron job has a pending or running job in the queue. */
|
|
282
|
+
export function hasPendingCronJob(cronJobUid: string): boolean {
|
|
283
|
+
const row = getDb()
|
|
284
|
+
.prepare(
|
|
285
|
+
`SELECT 1 FROM jobs WHERE cron_job_uid = ? AND status IN ('pending', 'running') LIMIT 1`,
|
|
286
|
+
)
|
|
287
|
+
.get(cronJobUid) as unknown | undefined;
|
|
288
|
+
return row != null;
|
|
289
|
+
}
|