@owloops/browserbird 1.0.2 → 1.0.3
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/bin/browserbird +7 -1
- package/dist/db-BsYEYsul.mjs +1011 -0
- package/dist/index.mjs +4748 -0
- package/package.json +6 -3
- package/src/channel/blocks.ts +0 -485
- package/src/channel/coalesce.ts +0 -79
- package/src/channel/commands.ts +0 -216
- package/src/channel/handler.ts +0 -272
- package/src/channel/slack.ts +0 -573
- package/src/channel/types.ts +0 -59
- package/src/cli/banner.ts +0 -10
- package/src/cli/birds.ts +0 -396
- package/src/cli/config.ts +0 -77
- package/src/cli/doctor.ts +0 -63
- package/src/cli/index.ts +0 -5
- package/src/cli/jobs.ts +0 -166
- package/src/cli/logs.ts +0 -67
- package/src/cli/run.ts +0 -148
- package/src/cli/sessions.ts +0 -158
- package/src/cli/style.ts +0 -19
- package/src/config.ts +0 -291
- package/src/core/logger.ts +0 -78
- package/src/core/redact.ts +0 -75
- package/src/core/types.ts +0 -83
- package/src/core/uid.ts +0 -26
- package/src/core/utils.ts +0 -137
- package/src/cron/parse.ts +0 -146
- package/src/cron/scheduler.ts +0 -242
- package/src/daemon.ts +0 -169
- package/src/db/auth.ts +0 -49
- package/src/db/birds.ts +0 -357
- package/src/db/core.ts +0 -377
- package/src/db/index.ts +0 -10
- package/src/db/jobs.ts +0 -289
- package/src/db/logs.ts +0 -64
- package/src/db/messages.ts +0 -79
- package/src/db/path.ts +0 -30
- package/src/db/sessions.ts +0 -165
- package/src/jobs.ts +0 -140
- package/src/provider/claude.test.ts +0 -95
- package/src/provider/claude.ts +0 -196
- package/src/provider/opencode.test.ts +0 -169
- package/src/provider/opencode.ts +0 -248
- package/src/provider/session.ts +0 -65
- package/src/provider/spawn.ts +0 -173
- package/src/provider/stream.ts +0 -67
- package/src/provider/types.ts +0 -24
- package/src/server/auth.ts +0 -135
- package/src/server/health.ts +0 -87
- package/src/server/http.ts +0 -132
- package/src/server/index.ts +0 -6
- package/src/server/lifecycle.ts +0 -135
- package/src/server/routes.ts +0 -1199
- package/src/server/sse.ts +0 -54
- package/src/server/static.ts +0 -45
- package/src/server/vnc-proxy.ts +0 -75
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { styleText } from "node:util";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
//#region \0rolldown/runtime.js
|
|
9
|
+
var __defProp = Object.defineProperty;
|
|
10
|
+
var __exportAll = (all, no_symbols) => {
|
|
11
|
+
let target = {};
|
|
12
|
+
for (var name in all) {
|
|
13
|
+
__defProp(target, name, {
|
|
14
|
+
get: all[name],
|
|
15
|
+
enumerable: true
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
if (!no_symbols) {
|
|
19
|
+
__defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
20
|
+
}
|
|
21
|
+
return target;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/core/logger.ts
|
|
26
|
+
/** @fileoverview Structured logger. Writes to stderr, respects NO_COLOR. */
|
|
27
|
+
function shouldUseColor() {
|
|
28
|
+
if (process.env["NO_COLOR"] !== void 0) return false;
|
|
29
|
+
if (process.env["TERM"] === "dumb") return false;
|
|
30
|
+
if (process.argv.includes("--no-color")) return false;
|
|
31
|
+
return process.stderr.isTTY === true;
|
|
32
|
+
}
|
|
33
|
+
const useColor = shouldUseColor();
|
|
34
|
+
function style(format, text) {
|
|
35
|
+
if (!useColor) return text;
|
|
36
|
+
return styleText(format, text);
|
|
37
|
+
}
|
|
38
|
+
const LOG_LEVELS = {
|
|
39
|
+
ERROR: 0,
|
|
40
|
+
WARN: 1,
|
|
41
|
+
INFO: 2,
|
|
42
|
+
DEBUG: 3
|
|
43
|
+
};
|
|
44
|
+
let currentLevel = LOG_LEVELS.INFO;
|
|
45
|
+
function timestamp() {
|
|
46
|
+
return (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
|
|
47
|
+
}
|
|
48
|
+
function write(prefix, message) {
|
|
49
|
+
process.stderr.write(`${style("dim", timestamp())} ${prefix} ${message}\n`);
|
|
50
|
+
}
|
|
51
|
+
const logger = {
|
|
52
|
+
error(message) {
|
|
53
|
+
if (currentLevel >= LOG_LEVELS.ERROR) write(style("red", "[error]"), message);
|
|
54
|
+
},
|
|
55
|
+
warn(message) {
|
|
56
|
+
if (currentLevel >= LOG_LEVELS.WARN) write(style("yellow", "[warn]"), message);
|
|
57
|
+
},
|
|
58
|
+
info(message) {
|
|
59
|
+
if (currentLevel >= LOG_LEVELS.INFO) write(style("blue", "[info]"), message);
|
|
60
|
+
},
|
|
61
|
+
debug(message) {
|
|
62
|
+
if (currentLevel >= LOG_LEVELS.DEBUG) write(style("dim", "[debug]"), message);
|
|
63
|
+
},
|
|
64
|
+
success(message) {
|
|
65
|
+
if (currentLevel >= LOG_LEVELS.INFO) write(style("green", "[ok]"), message);
|
|
66
|
+
},
|
|
67
|
+
setLevel(level) {
|
|
68
|
+
currentLevel = {
|
|
69
|
+
error: LOG_LEVELS.ERROR,
|
|
70
|
+
warn: LOG_LEVELS.WARN,
|
|
71
|
+
info: LOG_LEVELS.INFO,
|
|
72
|
+
debug: LOG_LEVELS.DEBUG
|
|
73
|
+
}[level] ?? LOG_LEVELS.INFO;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/db/core.ts
|
|
79
|
+
/** @fileoverview SQLite database lifecycle, migrations, and query utilities. */
|
|
80
|
+
const DEFAULT_PER_PAGE = 15;
|
|
81
|
+
const MAX_PER_PAGE = 100;
|
|
82
|
+
/**
|
|
83
|
+
* Parses a sort string into an SQL ORDER BY clause.
|
|
84
|
+
* Each token is a column name optionally prefixed with `-` for DESC.
|
|
85
|
+
* Only columns present in `allowedColumns` are included.
|
|
86
|
+
*/
|
|
87
|
+
function parseSort(raw, allowedColumns, fallback) {
|
|
88
|
+
if (!raw) return fallback;
|
|
89
|
+
const parts = [];
|
|
90
|
+
for (const token of raw.split(",")) {
|
|
91
|
+
const trimmed = token.trim();
|
|
92
|
+
if (!trimmed) continue;
|
|
93
|
+
const desc = trimmed.startsWith("-");
|
|
94
|
+
const col = desc ? trimmed.slice(1) : trimmed;
|
|
95
|
+
if (allowedColumns.has(col)) parts.push(`${col} ${desc ? "DESC" : "ASC"}`);
|
|
96
|
+
}
|
|
97
|
+
return parts.length > 0 ? parts.join(", ") : fallback;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Builds a parenthesized OR clause for LIKE-based search across columns.
|
|
101
|
+
* Returns empty sql/params when the search term is empty.
|
|
102
|
+
*/
|
|
103
|
+
function buildSearchClause(term, columns) {
|
|
104
|
+
if (!term || columns.length === 0) return {
|
|
105
|
+
sql: "",
|
|
106
|
+
params: []
|
|
107
|
+
};
|
|
108
|
+
const like = `%${term}%`;
|
|
109
|
+
return {
|
|
110
|
+
sql: `(${columns.map((c) => `${c} LIKE ?`).join(" OR ")})`,
|
|
111
|
+
params: columns.map(() => like)
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function paginate(table, page, perPage, whereOrOptions, params, orderBy) {
|
|
115
|
+
let where;
|
|
116
|
+
let allParams;
|
|
117
|
+
let resolvedOrderBy;
|
|
118
|
+
if (typeof whereOrOptions === "object" && whereOrOptions !== null) {
|
|
119
|
+
const opts = whereOrOptions;
|
|
120
|
+
const conditions = [];
|
|
121
|
+
allParams = [...opts.params ?? []];
|
|
122
|
+
if (opts.where) conditions.push(opts.where);
|
|
123
|
+
if (opts.search && opts.searchColumns && opts.searchColumns.length > 0) {
|
|
124
|
+
const sc = buildSearchClause(opts.search, opts.searchColumns);
|
|
125
|
+
if (sc.sql) {
|
|
126
|
+
conditions.push(sc.sql);
|
|
127
|
+
allParams.push(...sc.params);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
where = conditions.join(" AND ");
|
|
131
|
+
resolvedOrderBy = parseSort(opts.sort, opts.allowedSortColumns ?? /* @__PURE__ */ new Set(), opts.defaultSort ?? "created_at DESC");
|
|
132
|
+
} else {
|
|
133
|
+
where = whereOrOptions ?? "";
|
|
134
|
+
allParams = params ?? [];
|
|
135
|
+
resolvedOrderBy = orderBy ?? "created_at DESC";
|
|
136
|
+
}
|
|
137
|
+
const pp = Math.min(Math.max(perPage, 1), MAX_PER_PAGE);
|
|
138
|
+
const p = Math.max(page, 1);
|
|
139
|
+
const offset = (p - 1) * pp;
|
|
140
|
+
const countSql = `SELECT COUNT(*) as count FROM ${table}${where ? ` WHERE ${where}` : ""}`;
|
|
141
|
+
const totalItems = getDb().prepare(countSql).get(...allParams).count;
|
|
142
|
+
const totalPages = Math.max(Math.ceil(totalItems / pp), 1);
|
|
143
|
+
const dataSql = `SELECT * FROM ${table}${where ? ` WHERE ${where}` : ""} ORDER BY ${resolvedOrderBy} LIMIT ? OFFSET ?`;
|
|
144
|
+
return {
|
|
145
|
+
items: getDb().prepare(dataSql).all(...allParams, pp, offset),
|
|
146
|
+
page: p,
|
|
147
|
+
perPage: pp,
|
|
148
|
+
totalItems,
|
|
149
|
+
totalPages
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Versioned migration registry. Each entry runs once, in order.
|
|
154
|
+
* PRAGMA user_version tracks which migrations have been applied.
|
|
155
|
+
* All DDL uses IF NOT EXISTS for idempotency.
|
|
156
|
+
*/
|
|
157
|
+
const MIGRATIONS = [{
|
|
158
|
+
name: "initial schema",
|
|
159
|
+
up(d) {
|
|
160
|
+
d.exec(`
|
|
161
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
162
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
163
|
+
email TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
|
164
|
+
password_hash TEXT NOT NULL,
|
|
165
|
+
token_key TEXT NOT NULL,
|
|
166
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
170
|
+
key TEXT PRIMARY KEY,
|
|
171
|
+
value TEXT NOT NULL
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
175
|
+
uid TEXT PRIMARY KEY,
|
|
176
|
+
channel_id TEXT NOT NULL,
|
|
177
|
+
thread_id TEXT,
|
|
178
|
+
agent_id TEXT NOT NULL DEFAULT 'default',
|
|
179
|
+
provider_session_id TEXT NOT NULL,
|
|
180
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
181
|
+
last_active TEXT NOT NULL DEFAULT (datetime('now')),
|
|
182
|
+
message_count INTEGER NOT NULL DEFAULT 0,
|
|
183
|
+
UNIQUE(channel_id, thread_id)
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
CREATE TABLE IF NOT EXISTS cron_jobs (
|
|
187
|
+
uid TEXT PRIMARY KEY,
|
|
188
|
+
name TEXT NOT NULL,
|
|
189
|
+
agent_id TEXT NOT NULL DEFAULT 'default',
|
|
190
|
+
schedule TEXT NOT NULL,
|
|
191
|
+
prompt TEXT NOT NULL,
|
|
192
|
+
target_channel_id TEXT,
|
|
193
|
+
active_hours_start TEXT,
|
|
194
|
+
active_hours_end TEXT,
|
|
195
|
+
timezone TEXT DEFAULT 'UTC',
|
|
196
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
197
|
+
failure_count INTEGER NOT NULL DEFAULT 0,
|
|
198
|
+
last_run TEXT,
|
|
199
|
+
last_status TEXT,
|
|
200
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
CREATE TABLE IF NOT EXISTS cron_runs (
|
|
204
|
+
uid TEXT PRIMARY KEY,
|
|
205
|
+
job_uid TEXT NOT NULL REFERENCES cron_jobs(uid),
|
|
206
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
207
|
+
finished_at TEXT,
|
|
208
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
209
|
+
result TEXT,
|
|
210
|
+
error TEXT
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
214
|
+
id INTEGER PRIMARY KEY,
|
|
215
|
+
channel_id TEXT NOT NULL,
|
|
216
|
+
thread_id TEXT,
|
|
217
|
+
user_id TEXT NOT NULL,
|
|
218
|
+
direction TEXT NOT NULL CHECK(direction IN ('in', 'out')),
|
|
219
|
+
content TEXT,
|
|
220
|
+
tokens_in INTEGER,
|
|
221
|
+
tokens_out INTEGER,
|
|
222
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
226
|
+
id INTEGER PRIMARY KEY,
|
|
227
|
+
name TEXT NOT NULL,
|
|
228
|
+
payload TEXT,
|
|
229
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'running', 'completed', 'failed')),
|
|
230
|
+
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low')),
|
|
231
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
232
|
+
max_attempts INTEGER NOT NULL DEFAULT 1,
|
|
233
|
+
timeout INTEGER NOT NULL DEFAULT 1800,
|
|
234
|
+
cron_job_uid TEXT REFERENCES cron_jobs(uid),
|
|
235
|
+
run_at TEXT,
|
|
236
|
+
started_at TEXT,
|
|
237
|
+
completed_at TEXT,
|
|
238
|
+
result TEXT,
|
|
239
|
+
error TEXT,
|
|
240
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
CREATE TABLE IF NOT EXISTS logs (
|
|
244
|
+
id INTEGER PRIMARY KEY,
|
|
245
|
+
level TEXT NOT NULL DEFAULT 'info' CHECK(level IN ('debug', 'info', 'warn', 'error')),
|
|
246
|
+
source TEXT NOT NULL,
|
|
247
|
+
message TEXT NOT NULL,
|
|
248
|
+
channel_id TEXT,
|
|
249
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_channel_thread
|
|
253
|
+
ON sessions(channel_id, thread_id);
|
|
254
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_last_active
|
|
255
|
+
ON sessions(last_active);
|
|
256
|
+
CREATE INDEX IF NOT EXISTS idx_messages_channel_thread
|
|
257
|
+
ON messages(channel_id, thread_id);
|
|
258
|
+
CREATE INDEX IF NOT EXISTS idx_messages_created_at
|
|
259
|
+
ON messages(created_at DESC);
|
|
260
|
+
CREATE INDEX IF NOT EXISTS idx_cron_runs_job_uid
|
|
261
|
+
ON cron_runs(job_uid, started_at DESC);
|
|
262
|
+
CREATE INDEX IF NOT EXISTS idx_cron_jobs_enabled
|
|
263
|
+
ON cron_jobs(enabled);
|
|
264
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_poll
|
|
265
|
+
ON jobs(status, priority, run_at, created_at);
|
|
266
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_cron_job_uid
|
|
267
|
+
ON jobs(cron_job_uid);
|
|
268
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_stale
|
|
269
|
+
ON jobs(status, started_at);
|
|
270
|
+
CREATE INDEX IF NOT EXISTS idx_logs_created_at
|
|
271
|
+
ON logs(created_at DESC);
|
|
272
|
+
CREATE INDEX IF NOT EXISTS idx_logs_level_source
|
|
273
|
+
ON logs(level, source, created_at DESC);
|
|
274
|
+
`);
|
|
275
|
+
}
|
|
276
|
+
}];
|
|
277
|
+
let db = null;
|
|
278
|
+
function getSchemaVersion(d) {
|
|
279
|
+
return d.prepare("PRAGMA user_version").get().user_version;
|
|
280
|
+
}
|
|
281
|
+
function setSchemaVersion(d, version) {
|
|
282
|
+
d.exec(`PRAGMA user_version = ${version}`);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Runs pending migrations inside a transaction.
|
|
286
|
+
* Safe to call on every startup; already-applied migrations are skipped.
|
|
287
|
+
*/
|
|
288
|
+
function migrate(d) {
|
|
289
|
+
const current = getSchemaVersion(d);
|
|
290
|
+
const target = MIGRATIONS.length;
|
|
291
|
+
if (current >= target) return;
|
|
292
|
+
for (let i = current; i < target; i++) {
|
|
293
|
+
const migration = MIGRATIONS[i];
|
|
294
|
+
logger.info(`migration ${i + 1}/${target}: ${migration.name}`);
|
|
295
|
+
d.exec("BEGIN");
|
|
296
|
+
try {
|
|
297
|
+
migration.up(d);
|
|
298
|
+
setSchemaVersion(d, i + 1);
|
|
299
|
+
d.exec("COMMIT");
|
|
300
|
+
} catch (err) {
|
|
301
|
+
d.exec("ROLLBACK");
|
|
302
|
+
throw new Error(`migration "${migration.name}" failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Opens (or creates) the SQLite database at the given path.
|
|
308
|
+
* Configures WAL mode, runs pending migrations.
|
|
309
|
+
*/
|
|
310
|
+
function openDatabase(dbPath) {
|
|
311
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
312
|
+
db = new DatabaseSync(dbPath);
|
|
313
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
314
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
315
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
316
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
317
|
+
migrate(db);
|
|
318
|
+
logger.info(`database opened at ${dbPath}`);
|
|
319
|
+
}
|
|
320
|
+
function closeDatabase() {
|
|
321
|
+
if (db) {
|
|
322
|
+
db.close();
|
|
323
|
+
db = null;
|
|
324
|
+
logger.info("database closed");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function getDb() {
|
|
328
|
+
if (!db) throw new Error("Database not initialized. Call openDatabase() first.");
|
|
329
|
+
return db;
|
|
330
|
+
}
|
|
331
|
+
/** Runs WAL checkpoint and query planner optimization. Safe to call periodically. */
|
|
332
|
+
function optimizeDatabase() {
|
|
333
|
+
const d = getDb();
|
|
334
|
+
d.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
335
|
+
d.exec("PRAGMA optimize");
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Resolves a row by UID or UID prefix, like git's short SHA resolution.
|
|
339
|
+
* Exact match first (fast path), then prefix scan via LIKE.
|
|
340
|
+
*/
|
|
341
|
+
function resolveByUid(table, uidPrefix) {
|
|
342
|
+
const input = uidPrefix.toLowerCase();
|
|
343
|
+
const d = getDb();
|
|
344
|
+
const exact = d.prepare(`SELECT * FROM ${table} WHERE uid = ?`).get(input);
|
|
345
|
+
if (exact) return { row: exact };
|
|
346
|
+
const rows = d.prepare(`SELECT * FROM ${table} WHERE uid LIKE ? LIMIT 2`).all(`${input}%`);
|
|
347
|
+
if (rows.length === 0) return void 0;
|
|
348
|
+
if (rows.length > 1) return {
|
|
349
|
+
ambiguous: true,
|
|
350
|
+
count: d.prepare(`SELECT COUNT(*) as count FROM ${table} WHERE uid LIKE ?`).get(`${input}%`).count
|
|
351
|
+
};
|
|
352
|
+
return { row: rows[0] };
|
|
353
|
+
}
|
|
354
|
+
/** Wraps a function in BEGIN/COMMIT with automatic ROLLBACK on error. */
|
|
355
|
+
function transaction(fn) {
|
|
356
|
+
const d = getDb();
|
|
357
|
+
d.exec("BEGIN IMMEDIATE");
|
|
358
|
+
try {
|
|
359
|
+
const result = fn();
|
|
360
|
+
d.exec("COMMIT");
|
|
361
|
+
return result;
|
|
362
|
+
} catch (err) {
|
|
363
|
+
d.exec("ROLLBACK");
|
|
364
|
+
throw err;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
//#endregion
|
|
369
|
+
//#region src/core/uid.ts
|
|
370
|
+
/** @fileoverview Prefixed short ID generation (PocketBase/Motebase pattern). */
|
|
371
|
+
const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
372
|
+
const UID_PREFIX = {
|
|
373
|
+
bird: "br_",
|
|
374
|
+
flight: "fl_",
|
|
375
|
+
session: "ss_"
|
|
376
|
+
};
|
|
377
|
+
function generateUid(prefix) {
|
|
378
|
+
const bytes = randomBytes(15);
|
|
379
|
+
let id = prefix;
|
|
380
|
+
for (let i = 0; i < 15; i++) id += ALPHABET[bytes[i] % 36];
|
|
381
|
+
return id;
|
|
382
|
+
}
|
|
383
|
+
function shortUid(uid) {
|
|
384
|
+
const i = uid.indexOf("_");
|
|
385
|
+
if (i === -1) return uid.slice(0, 10);
|
|
386
|
+
return uid.slice(0, i + 1 + 7);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region src/db/sessions.ts
|
|
391
|
+
function findSession(channelId, threadId) {
|
|
392
|
+
return getDb().prepare("SELECT * FROM sessions WHERE channel_id = ? AND thread_id IS ?").get(channelId, threadId);
|
|
393
|
+
}
|
|
394
|
+
function createSession(channelId, threadId, agentId, providerSessionId) {
|
|
395
|
+
const uid = generateUid(UID_PREFIX.session);
|
|
396
|
+
return getDb().prepare(`INSERT INTO sessions (uid, channel_id, thread_id, agent_id, provider_session_id)
|
|
397
|
+
VALUES (?, ?, ?, ?, ?)
|
|
398
|
+
RETURNING *`).get(uid, channelId, threadId, agentId, providerSessionId);
|
|
399
|
+
}
|
|
400
|
+
function touchSession(uid, messageCountDelta = 1) {
|
|
401
|
+
getDb().prepare(`UPDATE sessions SET last_active = datetime('now'), message_count = message_count + ? WHERE uid = ?`).run(messageCountDelta, uid);
|
|
402
|
+
}
|
|
403
|
+
const SESSION_SORT_COLUMNS = new Set([
|
|
404
|
+
"uid",
|
|
405
|
+
"channel_id",
|
|
406
|
+
"agent_id",
|
|
407
|
+
"message_count",
|
|
408
|
+
"last_active",
|
|
409
|
+
"created_at"
|
|
410
|
+
]);
|
|
411
|
+
const SESSION_SEARCH_COLUMNS = [
|
|
412
|
+
"channel_id",
|
|
413
|
+
"thread_id",
|
|
414
|
+
"agent_id"
|
|
415
|
+
];
|
|
416
|
+
function listSessions(page = 1, perPage = DEFAULT_PER_PAGE, sort, search) {
|
|
417
|
+
return paginate("sessions", page, perPage, {
|
|
418
|
+
defaultSort: "last_active DESC",
|
|
419
|
+
sort,
|
|
420
|
+
search,
|
|
421
|
+
allowedSortColumns: SESSION_SORT_COLUMNS,
|
|
422
|
+
searchColumns: SESSION_SEARCH_COLUMNS
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function getSession(uid) {
|
|
426
|
+
return getDb().prepare("SELECT * FROM sessions WHERE uid = ?").get(uid);
|
|
427
|
+
}
|
|
428
|
+
const MESSAGE_SORT_COLUMNS = new Set([
|
|
429
|
+
"id",
|
|
430
|
+
"created_at",
|
|
431
|
+
"direction",
|
|
432
|
+
"user_id"
|
|
433
|
+
]);
|
|
434
|
+
const MESSAGE_SEARCH_COLUMNS = ["content", "user_id"];
|
|
435
|
+
function getSessionMessages(channelId, threadId, page = 1, perPage = DEFAULT_PER_PAGE, sort, search) {
|
|
436
|
+
const pp = Math.min(Math.max(perPage, 1), MAX_PER_PAGE);
|
|
437
|
+
const p = Math.max(page, 1);
|
|
438
|
+
const offset = (p - 1) * pp;
|
|
439
|
+
const conditions = ["channel_id = ? AND thread_id IS ?"];
|
|
440
|
+
const allParams = [channelId, threadId];
|
|
441
|
+
if (search) {
|
|
442
|
+
const sc = buildSearchClause(search, MESSAGE_SEARCH_COLUMNS);
|
|
443
|
+
if (sc.sql) {
|
|
444
|
+
conditions.push(sc.sql);
|
|
445
|
+
allParams.push(...sc.params);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const where = conditions.join(" AND ");
|
|
449
|
+
const orderBy = parseSort(sort, MESSAGE_SORT_COLUMNS, "created_at ASC, id ASC");
|
|
450
|
+
const totalItems = getDb().prepare(`SELECT COUNT(*) as count FROM messages WHERE ${where}`).get(...allParams).count;
|
|
451
|
+
const totalPages = Math.max(Math.ceil(totalItems / pp), 1);
|
|
452
|
+
return {
|
|
453
|
+
items: getDb().prepare(`SELECT * FROM messages
|
|
454
|
+
WHERE ${where}
|
|
455
|
+
ORDER BY ${orderBy}
|
|
456
|
+
LIMIT ? OFFSET ?`).all(...allParams, pp, offset),
|
|
457
|
+
page: p,
|
|
458
|
+
perPage: pp,
|
|
459
|
+
totalItems,
|
|
460
|
+
totalPages
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
function getSessionTokenStats(channelId, threadId) {
|
|
464
|
+
return getDb().prepare(`SELECT COALESCE(SUM(tokens_in), 0) as totalTokensIn,
|
|
465
|
+
COALESCE(SUM(tokens_out), 0) as totalTokensOut
|
|
466
|
+
FROM messages
|
|
467
|
+
WHERE channel_id = ? AND thread_id IS ?`).get(channelId, threadId);
|
|
468
|
+
}
|
|
469
|
+
function getSessionCount() {
|
|
470
|
+
return getDb().prepare("SELECT COUNT(*) as count FROM sessions").get().count;
|
|
471
|
+
}
|
|
472
|
+
function deleteStaleSessions(ttlHours) {
|
|
473
|
+
const result = getDb().prepare(`DELETE FROM sessions WHERE last_active < datetime('now', ? || ' hours')`).run(`-${ttlHours}`);
|
|
474
|
+
return Number(result.changes);
|
|
475
|
+
}
|
|
476
|
+
function updateSessionProviderId(uid, providerSessionId) {
|
|
477
|
+
getDb().prepare("UPDATE sessions SET provider_session_id = ? WHERE uid = ?").run(providerSessionId, uid);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
//#endregion
|
|
481
|
+
//#region src/db/birds.ts
|
|
482
|
+
const SYSTEM_CRON_PREFIX = "__bb_";
|
|
483
|
+
const CRON_SORT_COLUMNS = new Set([
|
|
484
|
+
"uid",
|
|
485
|
+
"name",
|
|
486
|
+
"schedule",
|
|
487
|
+
"agent_id",
|
|
488
|
+
"enabled",
|
|
489
|
+
"last_run",
|
|
490
|
+
"created_at"
|
|
491
|
+
]);
|
|
492
|
+
const CRON_SEARCH_COLUMNS = [
|
|
493
|
+
"uid",
|
|
494
|
+
"name",
|
|
495
|
+
"prompt",
|
|
496
|
+
"schedule"
|
|
497
|
+
];
|
|
498
|
+
function listCronJobs(page = 1, perPage = DEFAULT_PER_PAGE, includeSystem = false, sort, search) {
|
|
499
|
+
return paginate("cron_jobs", page, perPage, {
|
|
500
|
+
where: includeSystem ? "" : `name NOT LIKE '${SYSTEM_CRON_PREFIX}%'`,
|
|
501
|
+
defaultSort: "created_at ASC",
|
|
502
|
+
sort,
|
|
503
|
+
search,
|
|
504
|
+
allowedSortColumns: CRON_SORT_COLUMNS,
|
|
505
|
+
searchColumns: CRON_SEARCH_COLUMNS
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
function getEnabledCronJobs() {
|
|
509
|
+
return getDb().prepare("SELECT * FROM cron_jobs WHERE enabled = 1 ORDER BY created_at").all();
|
|
510
|
+
}
|
|
511
|
+
function createCronJob(name, schedule, prompt, targetChannelId, agentId, timezone, activeHoursStart, activeHoursEnd) {
|
|
512
|
+
const uid = generateUid(UID_PREFIX.bird);
|
|
513
|
+
return getDb().prepare(`INSERT INTO cron_jobs (uid, name, schedule, prompt, target_channel_id, agent_id, timezone, active_hours_start, active_hours_end)
|
|
514
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
515
|
+
RETURNING *`).get(uid, name, schedule, prompt, targetChannelId ?? null, agentId ?? "default", timezone ?? "UTC", activeHoursStart ?? null, activeHoursEnd ?? null);
|
|
516
|
+
}
|
|
517
|
+
function updateCronJobStatus(jobUid, status, failureCount) {
|
|
518
|
+
getDb().prepare(`UPDATE cron_jobs SET last_run = datetime('now'), last_status = ?, failure_count = ? WHERE uid = ?`).run(status, failureCount, jobUid);
|
|
519
|
+
}
|
|
520
|
+
function getCronJob(jobUid) {
|
|
521
|
+
return getDb().prepare("SELECT * FROM cron_jobs WHERE uid = ?").get(jobUid);
|
|
522
|
+
}
|
|
523
|
+
function setCronJobEnabled(jobUid, enabled) {
|
|
524
|
+
const result = getDb().prepare("UPDATE cron_jobs SET enabled = ? WHERE uid = ?").run(enabled ? 1 : 0, jobUid);
|
|
525
|
+
return Number(result.changes) > 0;
|
|
526
|
+
}
|
|
527
|
+
function updateCronJob(jobUid, fields) {
|
|
528
|
+
const sets = [];
|
|
529
|
+
const params = [];
|
|
530
|
+
if (fields.name !== void 0) {
|
|
531
|
+
sets.push("name = ?");
|
|
532
|
+
params.push(fields.name);
|
|
533
|
+
}
|
|
534
|
+
if (fields.schedule !== void 0) {
|
|
535
|
+
sets.push("schedule = ?");
|
|
536
|
+
params.push(fields.schedule);
|
|
537
|
+
}
|
|
538
|
+
if (fields.prompt !== void 0) {
|
|
539
|
+
sets.push("prompt = ?");
|
|
540
|
+
params.push(fields.prompt);
|
|
541
|
+
}
|
|
542
|
+
if (fields.targetChannelId !== void 0) {
|
|
543
|
+
sets.push("target_channel_id = ?");
|
|
544
|
+
params.push(fields.targetChannelId);
|
|
545
|
+
}
|
|
546
|
+
if (fields.agentId !== void 0) {
|
|
547
|
+
sets.push("agent_id = ?");
|
|
548
|
+
params.push(fields.agentId);
|
|
549
|
+
}
|
|
550
|
+
if (fields.timezone !== void 0) {
|
|
551
|
+
sets.push("timezone = ?");
|
|
552
|
+
params.push(fields.timezone);
|
|
553
|
+
}
|
|
554
|
+
if (fields.activeHoursStart !== void 0) {
|
|
555
|
+
sets.push("active_hours_start = ?");
|
|
556
|
+
params.push(fields.activeHoursStart);
|
|
557
|
+
}
|
|
558
|
+
if (fields.activeHoursEnd !== void 0) {
|
|
559
|
+
sets.push("active_hours_end = ?");
|
|
560
|
+
params.push(fields.activeHoursEnd);
|
|
561
|
+
}
|
|
562
|
+
if (sets.length === 0) return getCronJob(jobUid);
|
|
563
|
+
params.push(jobUid);
|
|
564
|
+
return getDb().prepare(`UPDATE cron_jobs SET ${sets.join(", ")} WHERE uid = ? RETURNING *`).get(...params);
|
|
565
|
+
}
|
|
566
|
+
function deleteCronJob(jobUid) {
|
|
567
|
+
const d = getDb();
|
|
568
|
+
d.exec("BEGIN");
|
|
569
|
+
try {
|
|
570
|
+
d.prepare("DELETE FROM cron_runs WHERE job_uid = ?").run(jobUid);
|
|
571
|
+
d.prepare("UPDATE jobs SET cron_job_uid = NULL WHERE cron_job_uid = ?").run(jobUid);
|
|
572
|
+
const result = d.prepare("DELETE FROM cron_jobs WHERE uid = ?").run(jobUid);
|
|
573
|
+
d.exec("COMMIT");
|
|
574
|
+
return Number(result.changes) > 0;
|
|
575
|
+
} catch (err) {
|
|
576
|
+
d.exec("ROLLBACK");
|
|
577
|
+
throw err;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const FLIGHT_SORT_COLUMNS = new Set([
|
|
581
|
+
"uid",
|
|
582
|
+
"started_at",
|
|
583
|
+
"finished_at",
|
|
584
|
+
"status",
|
|
585
|
+
"bird_name"
|
|
586
|
+
]);
|
|
587
|
+
const FLIGHT_SORT_MAP = { bird_name: "j.name" };
|
|
588
|
+
const FLIGHT_SEARCH_COLUMNS = [
|
|
589
|
+
"r.uid",
|
|
590
|
+
"r.job_uid",
|
|
591
|
+
"j.uid",
|
|
592
|
+
"j.name",
|
|
593
|
+
"r.error",
|
|
594
|
+
"r.result"
|
|
595
|
+
];
|
|
596
|
+
function listFlights(page = 1, perPage = DEFAULT_PER_PAGE, filters = {}, sort, search) {
|
|
597
|
+
const pp = Math.min(Math.max(perPage, 1), MAX_PER_PAGE);
|
|
598
|
+
const p = Math.max(page, 1);
|
|
599
|
+
const offset = (p - 1) * pp;
|
|
600
|
+
const conditions = [];
|
|
601
|
+
const params = [];
|
|
602
|
+
if (!filters.system) conditions.push(`j.name NOT LIKE '${SYSTEM_CRON_PREFIX}%'`);
|
|
603
|
+
if (filters.birdUid != null) {
|
|
604
|
+
conditions.push("r.job_uid = ?");
|
|
605
|
+
params.push(filters.birdUid);
|
|
606
|
+
}
|
|
607
|
+
if (filters.status) {
|
|
608
|
+
conditions.push("r.status = ?");
|
|
609
|
+
params.push(filters.status);
|
|
610
|
+
}
|
|
611
|
+
if (search) {
|
|
612
|
+
const sc = buildSearchClause(search, FLIGHT_SEARCH_COLUMNS);
|
|
613
|
+
if (sc.sql) {
|
|
614
|
+
conditions.push(sc.sql);
|
|
615
|
+
params.push(...sc.params);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
619
|
+
let orderBy = parseSort(sort, FLIGHT_SORT_COLUMNS, "r.started_at DESC");
|
|
620
|
+
for (const [key, qualified] of Object.entries(FLIGHT_SORT_MAP)) orderBy = orderBy.replaceAll(key, qualified);
|
|
621
|
+
orderBy = orderBy.replace(/(?<![a-z.])\b(uid|started_at|finished_at|status)\b/g, "r.$1");
|
|
622
|
+
const totalItems = getDb().prepare(`SELECT COUNT(*) as count FROM cron_runs r JOIN cron_jobs j ON j.uid = r.job_uid ${where}`).get(...params).count;
|
|
623
|
+
const totalPages = Math.max(Math.ceil(totalItems / pp), 1);
|
|
624
|
+
return {
|
|
625
|
+
items: getDb().prepare(`SELECT r.uid, r.job_uid, j.uid as bird_uid, j.name as bird_name,
|
|
626
|
+
r.started_at, r.finished_at, r.status, r.result, r.error
|
|
627
|
+
FROM cron_runs r
|
|
628
|
+
JOIN cron_jobs j ON j.uid = r.job_uid
|
|
629
|
+
${where}
|
|
630
|
+
ORDER BY ${orderBy}
|
|
631
|
+
LIMIT ? OFFSET ?`).all(...params, pp, offset),
|
|
632
|
+
page: p,
|
|
633
|
+
perPage: pp,
|
|
634
|
+
totalItems,
|
|
635
|
+
totalPages
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
function createCronRun(jobUid) {
|
|
639
|
+
const uid = generateUid(UID_PREFIX.flight);
|
|
640
|
+
return getDb().prepare("INSERT INTO cron_runs (uid, job_uid) VALUES (?, ?) RETURNING *").get(uid, jobUid);
|
|
641
|
+
}
|
|
642
|
+
function completeCronRun(runUid, status, result, error) {
|
|
643
|
+
getDb().prepare(`UPDATE cron_runs SET finished_at = datetime('now'), status = ?, result = ?, error = ? WHERE uid = ?`).run(status, result ?? null, error ?? null, runUid);
|
|
644
|
+
}
|
|
645
|
+
function getFlightStats() {
|
|
646
|
+
const rows = getDb().prepare(`SELECT r.status, COUNT(*) as count
|
|
647
|
+
FROM cron_runs r
|
|
648
|
+
JOIN cron_jobs j ON j.uid = r.job_uid
|
|
649
|
+
WHERE j.name NOT LIKE '${SYSTEM_CRON_PREFIX}%'
|
|
650
|
+
GROUP BY r.status`).all();
|
|
651
|
+
const stats = {
|
|
652
|
+
running: 0,
|
|
653
|
+
completed: 0,
|
|
654
|
+
failed: 0,
|
|
655
|
+
total: 0
|
|
656
|
+
};
|
|
657
|
+
for (const row of rows) {
|
|
658
|
+
if (row.status === "running") stats.running = row.count;
|
|
659
|
+
else if (row.status === "success") stats.completed = row.count;
|
|
660
|
+
else if (row.status === "error") stats.failed = row.count;
|
|
661
|
+
stats.total += row.count;
|
|
662
|
+
}
|
|
663
|
+
return stats;
|
|
664
|
+
}
|
|
665
|
+
function deleteOldCronRuns(retentionDays) {
|
|
666
|
+
const result = getDb().prepare(`DELETE FROM cron_runs WHERE started_at < datetime('now', ? || ' days')`).run(`-${retentionDays}`);
|
|
667
|
+
return Number(result.changes);
|
|
668
|
+
}
|
|
669
|
+
function ensureSystemCronJob(name, schedule, prompt) {
|
|
670
|
+
if (getDb().prepare("SELECT uid FROM cron_jobs WHERE name = ?").get(name)) return;
|
|
671
|
+
const uid = generateUid(UID_PREFIX.bird);
|
|
672
|
+
getDb().prepare(`INSERT INTO cron_jobs (uid, name, schedule, prompt, agent_id) VALUES (?, ?, ?, ?, 'system')`).run(uid, name, schedule, prompt);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
//#endregion
|
|
676
|
+
//#region src/db/jobs.ts
|
|
677
|
+
function createJob(options) {
|
|
678
|
+
const payload = options.payload != null ? JSON.stringify(options.payload) : null;
|
|
679
|
+
const priority = options.priority ?? "normal";
|
|
680
|
+
const maxAttempts = options.maxAttempts ?? 1;
|
|
681
|
+
const timeout = options.timeout ?? 1800;
|
|
682
|
+
const cronJobUid = options.cronJobUid ?? null;
|
|
683
|
+
if (options.delaySeconds) return getDb().prepare(`INSERT INTO jobs (name, payload, priority, max_attempts, timeout, cron_job_uid, run_at)
|
|
684
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now', '+' || ? || ' seconds'))
|
|
685
|
+
RETURNING *`).get(options.name, payload, priority, maxAttempts, timeout, cronJobUid, options.delaySeconds);
|
|
686
|
+
return getDb().prepare(`INSERT INTO jobs (name, payload, priority, max_attempts, timeout, cron_job_uid)
|
|
687
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
688
|
+
RETURNING *`).get(options.name, payload, priority, maxAttempts, timeout, cronJobUid);
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Atomically claims the next pending job for processing.
|
|
692
|
+
* Uses IMMEDIATE transaction to prevent race conditions.
|
|
693
|
+
* Priority order: high > normal > low, then by creation time.
|
|
694
|
+
*/
|
|
695
|
+
function claimNextJob() {
|
|
696
|
+
return transaction(() => {
|
|
697
|
+
const row = getDb().prepare(`SELECT * FROM jobs
|
|
698
|
+
WHERE status = 'pending' AND (run_at IS NULL OR run_at <= datetime('now'))
|
|
699
|
+
ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 WHEN 'low' THEN 2 END, created_at ASC
|
|
700
|
+
LIMIT 1`).get();
|
|
701
|
+
if (!row) return void 0;
|
|
702
|
+
getDb().prepare(`UPDATE jobs SET status = 'running', started_at = datetime('now'), attempts = attempts + 1
|
|
703
|
+
WHERE id = ?`).run(row.id);
|
|
704
|
+
return {
|
|
705
|
+
...row,
|
|
706
|
+
status: "running",
|
|
707
|
+
attempts: row.attempts + 1
|
|
708
|
+
};
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
function completeJob(jobId, result) {
|
|
712
|
+
getDb().prepare(`UPDATE jobs SET status = 'completed', completed_at = datetime('now'), result = ?
|
|
713
|
+
WHERE id = ?`).run(result ?? null, jobId);
|
|
714
|
+
}
|
|
715
|
+
function failJob(jobId, error) {
|
|
716
|
+
const job = getDb().prepare("SELECT attempts, max_attempts FROM jobs WHERE id = ?").get(jobId);
|
|
717
|
+
if (!job) return;
|
|
718
|
+
if (job.attempts < job.max_attempts) {
|
|
719
|
+
const delaySeconds = job.attempts * job.attempts;
|
|
720
|
+
getDb().prepare(`UPDATE jobs SET status = 'pending', error = ?,
|
|
721
|
+
run_at = datetime('now', '+' || ? || ' seconds')
|
|
722
|
+
WHERE id = ?`).run(error, delaySeconds, jobId);
|
|
723
|
+
} else getDb().prepare(`UPDATE jobs SET status = 'failed', completed_at = datetime('now'), error = ?
|
|
724
|
+
WHERE id = ?`).run(error, jobId);
|
|
725
|
+
}
|
|
726
|
+
/** Marks running jobs past their timeout as failed and cascades to linked cron_runs/cron_jobs. */
|
|
727
|
+
function failStaleJobs() {
|
|
728
|
+
const d = getDb();
|
|
729
|
+
const staleRows = d.prepare(`SELECT id, cron_job_uid FROM jobs
|
|
730
|
+
WHERE status = 'running'
|
|
731
|
+
AND started_at < datetime('now', '-' || timeout || ' seconds')`).all();
|
|
732
|
+
if (staleRows.length === 0) return 0;
|
|
733
|
+
const updateJob = d.prepare(`UPDATE jobs SET status = 'failed', error = 'timeout', completed_at = datetime('now')
|
|
734
|
+
WHERE id = ?`);
|
|
735
|
+
const updateRun = d.prepare(`UPDATE cron_runs SET status = 'error', error = 'timeout', finished_at = datetime('now')
|
|
736
|
+
WHERE job_uid = ? AND status = 'running'`);
|
|
737
|
+
const updateBird = d.prepare(`UPDATE cron_jobs SET last_status = 'failed', failure_count = failure_count + 1
|
|
738
|
+
WHERE uid = ?`);
|
|
739
|
+
for (const row of staleRows) {
|
|
740
|
+
updateJob.run(row.id);
|
|
741
|
+
if (row.cron_job_uid != null) {
|
|
742
|
+
updateRun.run(row.cron_job_uid);
|
|
743
|
+
updateBird.run(row.cron_job_uid);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return staleRows.length;
|
|
747
|
+
}
|
|
748
|
+
function deleteOldJobs(retentionDays) {
|
|
749
|
+
const stmt = getDb().prepare(`DELETE FROM jobs WHERE status IN ('completed', 'failed')
|
|
750
|
+
AND completed_at < datetime('now', ? || ' days')`);
|
|
751
|
+
return Number(stmt.run(`-${retentionDays}`).changes);
|
|
752
|
+
}
|
|
753
|
+
const JOB_SORT_COLUMNS = new Set([
|
|
754
|
+
"id",
|
|
755
|
+
"name",
|
|
756
|
+
"status",
|
|
757
|
+
"priority",
|
|
758
|
+
"created_at",
|
|
759
|
+
"started_at"
|
|
760
|
+
]);
|
|
761
|
+
const JOB_SEARCH_COLUMNS = ["name", "error"];
|
|
762
|
+
function listJobs(page = 1, perPage = DEFAULT_PER_PAGE, filters = {}, sort, search) {
|
|
763
|
+
const conditions = [];
|
|
764
|
+
const params = [];
|
|
765
|
+
if (filters.status) {
|
|
766
|
+
conditions.push("status = ?");
|
|
767
|
+
params.push(filters.status);
|
|
768
|
+
}
|
|
769
|
+
if (filters.cronJobUid != null) {
|
|
770
|
+
conditions.push("cron_job_uid = ?");
|
|
771
|
+
params.push(filters.cronJobUid);
|
|
772
|
+
}
|
|
773
|
+
if (filters.name) {
|
|
774
|
+
conditions.push("name LIKE ?");
|
|
775
|
+
params.push(`%${filters.name}%`);
|
|
776
|
+
}
|
|
777
|
+
return paginate("jobs", page, perPage, {
|
|
778
|
+
where: conditions.join(" AND "),
|
|
779
|
+
params,
|
|
780
|
+
defaultSort: "created_at DESC",
|
|
781
|
+
sort,
|
|
782
|
+
search,
|
|
783
|
+
allowedSortColumns: JOB_SORT_COLUMNS,
|
|
784
|
+
searchColumns: JOB_SEARCH_COLUMNS
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
function getJobStats() {
|
|
788
|
+
const rows = getDb().prepare("SELECT status, COUNT(*) as count FROM jobs GROUP BY status").all();
|
|
789
|
+
const stats = {
|
|
790
|
+
pending: 0,
|
|
791
|
+
running: 0,
|
|
792
|
+
completed: 0,
|
|
793
|
+
failed: 0,
|
|
794
|
+
total: 0
|
|
795
|
+
};
|
|
796
|
+
for (const row of rows) {
|
|
797
|
+
if (row.status in stats) stats[row.status] = row.count;
|
|
798
|
+
stats.total += row.count;
|
|
799
|
+
}
|
|
800
|
+
return stats;
|
|
801
|
+
}
|
|
802
|
+
function retryJob(jobId) {
|
|
803
|
+
const result = getDb().prepare(`UPDATE jobs SET status = 'pending', attempts = 0, error = NULL, result = NULL,
|
|
804
|
+
run_at = NULL, started_at = NULL, completed_at = NULL
|
|
805
|
+
WHERE id = ? AND status = 'failed'`).run(jobId);
|
|
806
|
+
return Number(result.changes) > 0;
|
|
807
|
+
}
|
|
808
|
+
function retryAllFailedJobs() {
|
|
809
|
+
const result = getDb().prepare(`UPDATE jobs SET status = 'pending', attempts = 0, error = NULL, result = NULL,
|
|
810
|
+
run_at = NULL, started_at = NULL, completed_at = NULL
|
|
811
|
+
WHERE status = 'failed'`).run();
|
|
812
|
+
return Number(result.changes);
|
|
813
|
+
}
|
|
814
|
+
function deleteJob(jobId) {
|
|
815
|
+
const result = getDb().prepare("DELETE FROM jobs WHERE id = ?").run(jobId);
|
|
816
|
+
return Number(result.changes) > 0;
|
|
817
|
+
}
|
|
818
|
+
function clearJobs(status) {
|
|
819
|
+
const result = getDb().prepare("DELETE FROM jobs WHERE status = ?").run(status);
|
|
820
|
+
return Number(result.changes);
|
|
821
|
+
}
|
|
822
|
+
/** Returns true if the given cron job has a pending or running job in the queue. */
|
|
823
|
+
function hasPendingCronJob(cronJobUid) {
|
|
824
|
+
return getDb().prepare(`SELECT 1 FROM jobs WHERE cron_job_uid = ? AND status IN ('pending', 'running') LIMIT 1`).get(cronJobUid) != null;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
//#endregion
|
|
828
|
+
//#region src/db/messages.ts
|
|
829
|
+
/** @fileoverview Message logging: message audit trail and token tracking. */
|
|
830
|
+
function logMessage(channelId, threadId, userId, direction, content, tokensIn, tokensOut) {
|
|
831
|
+
getDb().prepare(`INSERT INTO messages (channel_id, thread_id, user_id, direction, content, tokens_in, tokens_out)
|
|
832
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(channelId, threadId ?? null, userId, direction, content ?? null, tokensIn ?? null, tokensOut ?? null);
|
|
833
|
+
}
|
|
834
|
+
function getLastInboundMessage(channelId, threadId) {
|
|
835
|
+
const row = getDb().prepare(`SELECT content, created_at FROM messages
|
|
836
|
+
WHERE channel_id = ? AND thread_id IS ? AND direction = 'in' AND content IS NOT NULL
|
|
837
|
+
ORDER BY created_at DESC LIMIT 1`).get(channelId, threadId);
|
|
838
|
+
if (!row) return void 0;
|
|
839
|
+
return {
|
|
840
|
+
content: row.content,
|
|
841
|
+
timestamp: row.created_at
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
function deleteOldMessages(retentionDays) {
|
|
845
|
+
const result = getDb().prepare(`DELETE FROM messages WHERE created_at < datetime('now', ? || ' days')`).run(`-${retentionDays}`);
|
|
846
|
+
return Number(result.changes);
|
|
847
|
+
}
|
|
848
|
+
function getMessageStats() {
|
|
849
|
+
return getDb().prepare(`SELECT
|
|
850
|
+
COUNT(*) as totalMessages,
|
|
851
|
+
COALESCE(SUM(tokens_in), 0) as totalTokensIn,
|
|
852
|
+
COALESCE(SUM(tokens_out), 0) as totalTokensOut
|
|
853
|
+
FROM messages`).get();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
//#endregion
|
|
857
|
+
//#region src/db/logs.ts
|
|
858
|
+
function insertLog(level, source, message, channelId) {
|
|
859
|
+
getDb().prepare(`INSERT INTO logs (level, source, message, channel_id) VALUES (?, ?, ?, ?)`).run(level, source, message, channelId ?? null);
|
|
860
|
+
}
|
|
861
|
+
const LOG_SORT_COLUMNS = new Set([
|
|
862
|
+
"id",
|
|
863
|
+
"level",
|
|
864
|
+
"source",
|
|
865
|
+
"created_at"
|
|
866
|
+
]);
|
|
867
|
+
const LOG_SEARCH_COLUMNS = ["message", "source"];
|
|
868
|
+
function getRecentLogs(page = 1, perPage = DEFAULT_PER_PAGE, level, source, sort, search) {
|
|
869
|
+
const conditions = [];
|
|
870
|
+
const params = [];
|
|
871
|
+
if (level) {
|
|
872
|
+
conditions.push("level = ?");
|
|
873
|
+
params.push(level);
|
|
874
|
+
}
|
|
875
|
+
if (source) {
|
|
876
|
+
conditions.push("source = ?");
|
|
877
|
+
params.push(source);
|
|
878
|
+
}
|
|
879
|
+
return paginate("logs", page, perPage, {
|
|
880
|
+
where: conditions.join(" AND "),
|
|
881
|
+
params,
|
|
882
|
+
defaultSort: "created_at DESC",
|
|
883
|
+
sort,
|
|
884
|
+
search,
|
|
885
|
+
allowedSortColumns: LOG_SORT_COLUMNS,
|
|
886
|
+
searchColumns: LOG_SEARCH_COLUMNS
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
function deleteOldLogs(retentionDays) {
|
|
890
|
+
const stmt = getDb().prepare(`DELETE FROM logs WHERE created_at < datetime('now', ? || ' days')`);
|
|
891
|
+
return Number(stmt.run(`-${retentionDays}`).changes);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
//#endregion
|
|
895
|
+
//#region src/db/auth.ts
|
|
896
|
+
/** @fileoverview User and settings persistence for the auth system. */
|
|
897
|
+
function getUserCount() {
|
|
898
|
+
return getDb().prepare("SELECT COUNT(*) as count FROM users").get().count;
|
|
899
|
+
}
|
|
900
|
+
function getUserByEmail(email) {
|
|
901
|
+
return getDb().prepare("SELECT * FROM users WHERE email = ?").get(email);
|
|
902
|
+
}
|
|
903
|
+
function getUserById(id) {
|
|
904
|
+
return getDb().prepare("SELECT * FROM users WHERE id = ?").get(id);
|
|
905
|
+
}
|
|
906
|
+
function createUser(email, passwordHash, tokenKey) {
|
|
907
|
+
getDb().prepare("INSERT INTO users (email, password_hash, token_key) VALUES (?, ?, ?)").run(email, passwordHash, tokenKey);
|
|
908
|
+
return getUserByEmail(email);
|
|
909
|
+
}
|
|
910
|
+
function getSetting(key) {
|
|
911
|
+
return getDb().prepare("SELECT value FROM settings WHERE key = ?").get(key)?.value;
|
|
912
|
+
}
|
|
913
|
+
function setSetting(key, value) {
|
|
914
|
+
getDb().prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(key, value);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
//#endregion
|
|
918
|
+
//#region src/db/path.ts
|
|
919
|
+
/** @fileoverview Database path resolution: CLI flag, env var, or default. */
|
|
920
|
+
const DEFAULT_DB_PATH = resolve(".browserbird", "browserbird.db");
|
|
921
|
+
/**
|
|
922
|
+
* Resolves the database file path.
|
|
923
|
+
* Priority: explicit value > BROWSERBIRD_DB env var > default.
|
|
924
|
+
*/
|
|
925
|
+
function resolveDbPath(explicit) {
|
|
926
|
+
if (explicit) return resolve(explicit);
|
|
927
|
+
const envValue = process.env["BROWSERBIRD_DB"];
|
|
928
|
+
if (envValue) return resolve(envValue);
|
|
929
|
+
return DEFAULT_DB_PATH;
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Extracts --db value from a raw argv array, then resolves.
|
|
933
|
+
* Used by CLI command handlers that receive argv directly.
|
|
934
|
+
*/
|
|
935
|
+
function resolveDbPathFromArgv(argv) {
|
|
936
|
+
const idx = argv.indexOf("--db");
|
|
937
|
+
if (idx !== -1 && idx + 1 < argv.length) return resolveDbPath(argv[idx + 1]);
|
|
938
|
+
return resolveDbPath();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
//#endregion
|
|
942
|
+
//#region src/db/index.ts
|
|
943
|
+
var db_exports = /* @__PURE__ */ __exportAll({
|
|
944
|
+
DEFAULT_PER_PAGE: () => DEFAULT_PER_PAGE,
|
|
945
|
+
MAX_PER_PAGE: () => MAX_PER_PAGE,
|
|
946
|
+
SYSTEM_CRON_PREFIX: () => SYSTEM_CRON_PREFIX,
|
|
947
|
+
buildSearchClause: () => buildSearchClause,
|
|
948
|
+
claimNextJob: () => claimNextJob,
|
|
949
|
+
clearJobs: () => clearJobs,
|
|
950
|
+
closeDatabase: () => closeDatabase,
|
|
951
|
+
completeCronRun: () => completeCronRun,
|
|
952
|
+
completeJob: () => completeJob,
|
|
953
|
+
createCronJob: () => createCronJob,
|
|
954
|
+
createCronRun: () => createCronRun,
|
|
955
|
+
createJob: () => createJob,
|
|
956
|
+
createSession: () => createSession,
|
|
957
|
+
createUser: () => createUser,
|
|
958
|
+
deleteCronJob: () => deleteCronJob,
|
|
959
|
+
deleteJob: () => deleteJob,
|
|
960
|
+
deleteOldCronRuns: () => deleteOldCronRuns,
|
|
961
|
+
deleteOldJobs: () => deleteOldJobs,
|
|
962
|
+
deleteOldLogs: () => deleteOldLogs,
|
|
963
|
+
deleteOldMessages: () => deleteOldMessages,
|
|
964
|
+
deleteStaleSessions: () => deleteStaleSessions,
|
|
965
|
+
ensureSystemCronJob: () => ensureSystemCronJob,
|
|
966
|
+
failJob: () => failJob,
|
|
967
|
+
failStaleJobs: () => failStaleJobs,
|
|
968
|
+
findSession: () => findSession,
|
|
969
|
+
getCronJob: () => getCronJob,
|
|
970
|
+
getDb: () => getDb,
|
|
971
|
+
getEnabledCronJobs: () => getEnabledCronJobs,
|
|
972
|
+
getFlightStats: () => getFlightStats,
|
|
973
|
+
getJobStats: () => getJobStats,
|
|
974
|
+
getLastInboundMessage: () => getLastInboundMessage,
|
|
975
|
+
getMessageStats: () => getMessageStats,
|
|
976
|
+
getRecentLogs: () => getRecentLogs,
|
|
977
|
+
getSession: () => getSession,
|
|
978
|
+
getSessionCount: () => getSessionCount,
|
|
979
|
+
getSessionMessages: () => getSessionMessages,
|
|
980
|
+
getSessionTokenStats: () => getSessionTokenStats,
|
|
981
|
+
getSetting: () => getSetting,
|
|
982
|
+
getUserByEmail: () => getUserByEmail,
|
|
983
|
+
getUserById: () => getUserById,
|
|
984
|
+
getUserCount: () => getUserCount,
|
|
985
|
+
hasPendingCronJob: () => hasPendingCronJob,
|
|
986
|
+
insertLog: () => insertLog,
|
|
987
|
+
listCronJobs: () => listCronJobs,
|
|
988
|
+
listFlights: () => listFlights,
|
|
989
|
+
listJobs: () => listJobs,
|
|
990
|
+
listSessions: () => listSessions,
|
|
991
|
+
logMessage: () => logMessage,
|
|
992
|
+
openDatabase: () => openDatabase,
|
|
993
|
+
optimizeDatabase: () => optimizeDatabase,
|
|
994
|
+
paginate: () => paginate,
|
|
995
|
+
parseSort: () => parseSort,
|
|
996
|
+
resolveByUid: () => resolveByUid,
|
|
997
|
+
resolveDbPath: () => resolveDbPath,
|
|
998
|
+
resolveDbPathFromArgv: () => resolveDbPathFromArgv,
|
|
999
|
+
retryAllFailedJobs: () => retryAllFailedJobs,
|
|
1000
|
+
retryJob: () => retryJob,
|
|
1001
|
+
setCronJobEnabled: () => setCronJobEnabled,
|
|
1002
|
+
setSetting: () => setSetting,
|
|
1003
|
+
touchSession: () => touchSession,
|
|
1004
|
+
transaction: () => transaction,
|
|
1005
|
+
updateCronJob: () => updateCronJob,
|
|
1006
|
+
updateCronJobStatus: () => updateCronJobStatus,
|
|
1007
|
+
updateSessionProviderId: () => updateSessionProviderId
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
//#endregion
|
|
1011
|
+
export { updateSessionProviderId as $, completeCronRun as A, listFlights as B, failStaleJobs as C, retryAllFailedJobs as D, listJobs as E, ensureSystemCronJob as F, deleteStaleSessions as G, updateCronJob as H, getCronJob as I, getSessionCount as J, findSession as K, getEnabledCronJobs as L, createCronRun as M, deleteCronJob as N, retryJob as O, deleteOldCronRuns as P, touchSession as Q, getFlightStats as R, failJob as S, hasPendingCronJob as T, updateCronJobStatus as U, setCronJobEnabled as V, createSession as W, getSessionTokenStats as X, getSessionMessages as Y, listSessions as Z, clearJobs as _, getSetting as a, logger as at, deleteJob as b, getUserCount as c, getRecentLogs as d, shortUid as et, insertLog as f, claimNextJob as g, logMessage as h, createUser as i, resolveByUid as it, createCronJob as j, SYSTEM_CRON_PREFIX as k, setSetting as l, getMessageStats as m, resolveDbPath as n, openDatabase as nt, getUserByEmail as o, deleteOldMessages as p, getSession as q, resolveDbPathFromArgv as r, optimizeDatabase as rt, getUserById as s, db_exports as t, closeDatabase as tt, deleteOldLogs as u, completeJob as v, getJobStats as w, deleteOldJobs as x, createJob as y, listCronJobs as z };
|