@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
package/src/cron/parse.ts
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/** @fileoverview Cron expression parser. Standard 5-field syntax + common macros. */
|
|
2
|
-
|
|
3
|
-
import { isWithinTimeRange } from '../core/utils.ts';
|
|
4
|
-
|
|
5
|
-
const MACROS: Record<string, string> = {
|
|
6
|
-
'@yearly': '0 0 1 1 *',
|
|
7
|
-
'@annually': '0 0 1 1 *',
|
|
8
|
-
'@monthly': '0 0 1 * *',
|
|
9
|
-
'@weekly': '0 0 * * 0',
|
|
10
|
-
'@daily': '0 0 * * *',
|
|
11
|
-
'@midnight': '0 0 * * *',
|
|
12
|
-
'@hourly': '0 * * * *',
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export interface CronSchedule {
|
|
16
|
-
minutes: Set<number>;
|
|
17
|
-
hours: Set<number>;
|
|
18
|
-
daysOfMonth: Set<number>;
|
|
19
|
-
months: Set<number>;
|
|
20
|
-
daysOfWeek: Set<number>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Parses a single cron field (e.g. `"*"`, `"1,5,10"`, `"9-17"`, `"* /15"`).
|
|
25
|
-
* Returns a Set of integer values that match.
|
|
26
|
-
*/
|
|
27
|
-
function parseField(field: string, min: number, max: number): Set<number> {
|
|
28
|
-
const values = new Set<number>();
|
|
29
|
-
|
|
30
|
-
for (const part of field.split(',')) {
|
|
31
|
-
const stepParts = part.split('/');
|
|
32
|
-
const range = stepParts[0]!;
|
|
33
|
-
const step = stepParts[1] != null ? parseInt(stepParts[1], 10) : 1;
|
|
34
|
-
|
|
35
|
-
let start: number;
|
|
36
|
-
let end: number;
|
|
37
|
-
|
|
38
|
-
if (range === '*') {
|
|
39
|
-
start = min;
|
|
40
|
-
end = max;
|
|
41
|
-
} else if (range.includes('-')) {
|
|
42
|
-
const [lo, hi] = range.split('-');
|
|
43
|
-
start = parseInt(lo!, 10);
|
|
44
|
-
end = parseInt(hi!, 10);
|
|
45
|
-
} else {
|
|
46
|
-
start = parseInt(range, 10);
|
|
47
|
-
end = start;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
for (let i = start; i <= end; i += step) {
|
|
51
|
-
values.add(i);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return values;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Parses a cron expression string into a CronSchedule. */
|
|
59
|
-
export function parseCron(expression: string): CronSchedule {
|
|
60
|
-
const expanded = MACROS[expression.toLowerCase()] ?? expression;
|
|
61
|
-
const fields = expanded.trim().split(/\s+/);
|
|
62
|
-
|
|
63
|
-
if (fields.length !== 5) {
|
|
64
|
-
throw new Error(`invalid cron expression: expected 5 fields, got ${fields.length}`);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return {
|
|
68
|
-
minutes: parseField(fields[0]!, 0, 59),
|
|
69
|
-
hours: parseField(fields[1]!, 0, 23),
|
|
70
|
-
daysOfMonth: parseField(fields[2]!, 1, 31),
|
|
71
|
-
months: parseField(fields[3]!, 1, 12),
|
|
72
|
-
daysOfWeek: parseField(fields[4]!, 0, 6),
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Returns true if the current time falls within the active hours window.
|
|
78
|
-
* When both start and end are null, the bird is always active.
|
|
79
|
-
* Hours are evaluated in the bird's configured timezone.
|
|
80
|
-
*/
|
|
81
|
-
export function isWithinActiveHours(
|
|
82
|
-
start: string | null,
|
|
83
|
-
end: string | null,
|
|
84
|
-
date: Date,
|
|
85
|
-
timezone?: string,
|
|
86
|
-
): boolean {
|
|
87
|
-
if (start == null && end == null) return true;
|
|
88
|
-
return isWithinTimeRange(start ?? '00:00', end ?? '24:00', date, timezone || 'UTC');
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Returns the next Date (after `after`) that matches the cron schedule,
|
|
93
|
-
* or null if no match is found within the search window (default: 366 days).
|
|
94
|
-
*/
|
|
95
|
-
export function nextCronMatch(
|
|
96
|
-
schedule: CronSchedule,
|
|
97
|
-
after: Date,
|
|
98
|
-
timezone?: string,
|
|
99
|
-
maxMinutes = 527_040,
|
|
100
|
-
): Date | null {
|
|
101
|
-
const candidate = new Date(after.getTime());
|
|
102
|
-
candidate.setSeconds(0, 0);
|
|
103
|
-
candidate.setTime(candidate.getTime() + 60_000);
|
|
104
|
-
|
|
105
|
-
for (let i = 0; i < maxMinutes; i++) {
|
|
106
|
-
if (matchesCron(schedule, candidate, timezone)) return candidate;
|
|
107
|
-
candidate.setTime(candidate.getTime() + 60_000);
|
|
108
|
-
}
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Returns true if the given Date matches the cron schedule in the specified timezone. */
|
|
113
|
-
export function matchesCron(schedule: CronSchedule, date: Date, timezone?: string): boolean {
|
|
114
|
-
const tz = timezone || 'UTC';
|
|
115
|
-
const parts = new Intl.DateTimeFormat('en-US', {
|
|
116
|
-
timeZone: tz,
|
|
117
|
-
hour: 'numeric',
|
|
118
|
-
minute: 'numeric',
|
|
119
|
-
day: 'numeric',
|
|
120
|
-
month: 'numeric',
|
|
121
|
-
weekday: 'short',
|
|
122
|
-
hour12: false,
|
|
123
|
-
}).formatToParts(date);
|
|
124
|
-
|
|
125
|
-
const get = (type: Intl.DateTimeFormatPartTypes): number =>
|
|
126
|
-
Number(parts.find((p) => p.type === type)?.value ?? 0);
|
|
127
|
-
|
|
128
|
-
const weekdayStr = parts.find((p) => p.type === 'weekday')?.value ?? '';
|
|
129
|
-
const weekdayMap: Record<string, number> = {
|
|
130
|
-
Sun: 0,
|
|
131
|
-
Mon: 1,
|
|
132
|
-
Tue: 2,
|
|
133
|
-
Wed: 3,
|
|
134
|
-
Thu: 4,
|
|
135
|
-
Fri: 5,
|
|
136
|
-
Sat: 6,
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
return (
|
|
140
|
-
schedule.minutes.has(get('minute')) &&
|
|
141
|
-
schedule.hours.has(get('hour')) &&
|
|
142
|
-
schedule.daysOfMonth.has(get('day')) &&
|
|
143
|
-
schedule.months.has(get('month')) &&
|
|
144
|
-
schedule.daysOfWeek.has(weekdayMap[weekdayStr] ?? 0)
|
|
145
|
-
);
|
|
146
|
-
}
|
package/src/cron/scheduler.ts
DELETED
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
/** @fileoverview Cron scheduler: evaluates cron jobs every 60s and enqueues due jobs. */
|
|
2
|
-
|
|
3
|
-
import type { Config } from '../core/types.ts';
|
|
4
|
-
import type { Block } from '../channel/blocks.ts';
|
|
5
|
-
import type { CronSchedule } from './parse.ts';
|
|
6
|
-
import type { StreamEventCompletion } from '../provider/stream.ts';
|
|
7
|
-
|
|
8
|
-
import { logger } from '../core/logger.ts';
|
|
9
|
-
import { shortUid } from '../core/uid.ts';
|
|
10
|
-
import {
|
|
11
|
-
SYSTEM_CRON_PREFIX,
|
|
12
|
-
getEnabledCronJobs,
|
|
13
|
-
updateCronJobStatus,
|
|
14
|
-
setCronJobEnabled,
|
|
15
|
-
ensureSystemCronJob,
|
|
16
|
-
hasPendingCronJob,
|
|
17
|
-
deleteOldMessages,
|
|
18
|
-
deleteOldCronRuns,
|
|
19
|
-
deleteOldJobs,
|
|
20
|
-
deleteOldLogs,
|
|
21
|
-
optimizeDatabase,
|
|
22
|
-
} from '../db/index.ts';
|
|
23
|
-
import { expireStaleSessions } from '../provider/session.ts';
|
|
24
|
-
import { registerHandler, enqueue } from '../jobs.ts';
|
|
25
|
-
import { broadcastSSE } from '../server/index.ts';
|
|
26
|
-
import { spawnProvider } from '../provider/spawn.ts';
|
|
27
|
-
import { redact } from '../core/redact.ts';
|
|
28
|
-
import { parseCron, matchesCron, isWithinActiveHours } from './parse.ts';
|
|
29
|
-
import { sessionCompleteBlocks, sessionErrorBlocks } from '../channel/blocks.ts';
|
|
30
|
-
|
|
31
|
-
const TICK_INTERVAL_MS = 60_000;
|
|
32
|
-
const MAX_SCHEDULE_ERRORS = 3;
|
|
33
|
-
|
|
34
|
-
interface CronRunPayload {
|
|
35
|
-
cronJobUid: string;
|
|
36
|
-
prompt: string;
|
|
37
|
-
channelId: string | null;
|
|
38
|
-
agentId: string;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
interface SystemCronPayload {
|
|
42
|
-
cronJobUid: string;
|
|
43
|
-
cronName: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface SchedulerDeps {
|
|
47
|
-
postToSlack?: (channel: string, text: string, opts?: { blocks?: Block[] }) => Promise<void>;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
type SystemCronHandler = () => string | void;
|
|
51
|
-
|
|
52
|
-
const systemHandlers = new Map<string, SystemCronHandler>();
|
|
53
|
-
|
|
54
|
-
function registerSystemCronJobs(config: Config, retentionDays: number): void {
|
|
55
|
-
const cleanupName = `${SYSTEM_CRON_PREFIX}db_cleanup__`;
|
|
56
|
-
const optimizeName = `${SYSTEM_CRON_PREFIX}db_optimize__`;
|
|
57
|
-
|
|
58
|
-
systemHandlers.set(cleanupName, () => {
|
|
59
|
-
expireStaleSessions(config.sessions.ttlHours);
|
|
60
|
-
const msgs = deleteOldMessages(retentionDays);
|
|
61
|
-
const runs = deleteOldCronRuns(retentionDays);
|
|
62
|
-
const jobs = deleteOldJobs(retentionDays);
|
|
63
|
-
const logs = deleteOldLogs(retentionDays);
|
|
64
|
-
if (msgs > 0 || runs > 0 || jobs > 0 || logs > 0) {
|
|
65
|
-
const summary = `${msgs} messages, ${runs} flight logs, ${jobs} jobs, ${logs} logs older than ${retentionDays}d`;
|
|
66
|
-
logger.info(`system cleanup: ${summary}`);
|
|
67
|
-
return summary;
|
|
68
|
-
}
|
|
69
|
-
return 'nothing to clean';
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
systemHandlers.set(optimizeName, () => {
|
|
73
|
-
optimizeDatabase();
|
|
74
|
-
logger.debug('system optimize: database optimized');
|
|
75
|
-
return 'database optimized';
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
ensureSystemCronJob(cleanupName, '0 */6 * * *', 'Database cleanup');
|
|
79
|
-
ensureSystemCronJob(optimizeName, '0 3 * * *', 'Database optimization');
|
|
80
|
-
logger.info('system birds registered');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Registers the cron_run job handler and starts the scheduler tick loop. */
|
|
84
|
-
export function startScheduler(config: Config, signal: AbortSignal, deps?: SchedulerDeps): void {
|
|
85
|
-
registerSystemCronJobs(config, config.database.retentionDays);
|
|
86
|
-
|
|
87
|
-
registerHandler('cron_run', async (raw) => {
|
|
88
|
-
const payload = raw as CronRunPayload;
|
|
89
|
-
const agent = config.agents.find((a) => a.id === payload.agentId);
|
|
90
|
-
if (!agent) {
|
|
91
|
-
throw new Error(`agent "${payload.agentId}" not found`);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const { events } = spawnProvider(
|
|
95
|
-
agent.provider,
|
|
96
|
-
{ message: payload.prompt, agent, mcpConfigPath: config.browser.mcpConfigPath },
|
|
97
|
-
signal,
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
let result = '';
|
|
101
|
-
let completion: StreamEventCompletion | undefined;
|
|
102
|
-
for await (const event of events) {
|
|
103
|
-
if (event.type === 'text_delta') {
|
|
104
|
-
result += redact(event.delta);
|
|
105
|
-
} else if (event.type === 'completion') {
|
|
106
|
-
completion = event;
|
|
107
|
-
} else if (event.type === 'rate_limit') {
|
|
108
|
-
logger.debug(
|
|
109
|
-
`bird ${shortUid(payload.cronJobUid)} rate limit window resets ${new Date(event.resetsAt * 1000).toISOString()}`,
|
|
110
|
-
);
|
|
111
|
-
} else if (event.type === 'error') {
|
|
112
|
-
const safeError = redact(event.error);
|
|
113
|
-
if (payload.channelId && deps?.postToSlack) {
|
|
114
|
-
const blocks = sessionErrorBlocks(safeError, { birdName: agent.name });
|
|
115
|
-
await deps.postToSlack(payload.channelId, `Bird failed: ${safeError}`, { blocks });
|
|
116
|
-
}
|
|
117
|
-
throw new Error(safeError);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (!result) {
|
|
122
|
-
logger.info(`bird ${shortUid(payload.cronJobUid)} completed (no output)`);
|
|
123
|
-
return 'completed (no output)';
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (payload.channelId && deps?.postToSlack) {
|
|
127
|
-
if (completion) {
|
|
128
|
-
const summary = result.length > 2800 ? result.slice(0, 2800) + '...' : result;
|
|
129
|
-
const blocks = sessionCompleteBlocks(completion, summary, agent.name);
|
|
130
|
-
const fallback = `Bird ${agent.name} completed: ${completion.numTurns} turns`;
|
|
131
|
-
await deps.postToSlack(payload.channelId, fallback, { blocks });
|
|
132
|
-
} else {
|
|
133
|
-
await deps.postToSlack(payload.channelId, result);
|
|
134
|
-
}
|
|
135
|
-
logger.info(`bird ${shortUid(payload.cronJobUid)} result posted to ${payload.channelId}`);
|
|
136
|
-
} else {
|
|
137
|
-
logger.info(`bird ${shortUid(payload.cronJobUid)} completed (${result.length} chars)`);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return result;
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
registerHandler('system_cron_run', (raw) => {
|
|
144
|
-
const payload = raw as SystemCronPayload;
|
|
145
|
-
const handler = systemHandlers.get(payload.cronName);
|
|
146
|
-
if (!handler) {
|
|
147
|
-
throw new Error(`no system handler for "${payload.cronName}"`);
|
|
148
|
-
}
|
|
149
|
-
const result = handler();
|
|
150
|
-
logger.info(`${payload.cronName}: ${result ?? 'done'}`);
|
|
151
|
-
return result ?? undefined;
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
const scheduleCache = new Map<string, CronSchedule>();
|
|
155
|
-
const scheduleErrors = new Map<string, number>();
|
|
156
|
-
|
|
157
|
-
const tick = () => {
|
|
158
|
-
if (signal.aborted) return;
|
|
159
|
-
|
|
160
|
-
const now = new Date();
|
|
161
|
-
const jobs = getEnabledCronJobs();
|
|
162
|
-
|
|
163
|
-
for (const job of jobs) {
|
|
164
|
-
let schedule = scheduleCache.get(job.uid);
|
|
165
|
-
if (!schedule) {
|
|
166
|
-
try {
|
|
167
|
-
schedule = parseCron(job.schedule);
|
|
168
|
-
scheduleCache.set(job.uid, schedule);
|
|
169
|
-
scheduleErrors.delete(job.uid);
|
|
170
|
-
} catch {
|
|
171
|
-
const count = (scheduleErrors.get(job.uid) ?? 0) + 1;
|
|
172
|
-
scheduleErrors.set(job.uid, count);
|
|
173
|
-
if (count >= MAX_SCHEDULE_ERRORS) {
|
|
174
|
-
logger.warn(
|
|
175
|
-
`bird ${shortUid(job.uid)}: invalid expression "${job.schedule}" (${count} consecutive failures), disabling`,
|
|
176
|
-
);
|
|
177
|
-
setCronJobEnabled(job.uid, false);
|
|
178
|
-
scheduleErrors.delete(job.uid);
|
|
179
|
-
} else {
|
|
180
|
-
logger.warn(
|
|
181
|
-
`bird ${shortUid(job.uid)}: invalid expression "${job.schedule}" (attempt ${count}/${MAX_SCHEDULE_ERRORS})`,
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (!matchesCron(schedule, now, job.timezone)) continue;
|
|
189
|
-
|
|
190
|
-
if (!isWithinActiveHours(job.active_hours_start, job.active_hours_end, now, job.timezone)) {
|
|
191
|
-
logger.debug(`bird ${shortUid(job.uid)} skipped: outside active hours`);
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (hasPendingCronJob(job.uid)) {
|
|
196
|
-
logger.debug(`bird ${shortUid(job.uid)} skipped: previous run still pending or running`);
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const isSystem = job.name.startsWith(SYSTEM_CRON_PREFIX);
|
|
201
|
-
|
|
202
|
-
logger.info(`bird ${shortUid(job.uid)} triggered: "${job.schedule}"`);
|
|
203
|
-
|
|
204
|
-
if (isSystem) {
|
|
205
|
-
enqueue(
|
|
206
|
-
'system_cron_run',
|
|
207
|
-
{ cronJobUid: job.uid, cronName: job.name } satisfies SystemCronPayload,
|
|
208
|
-
{ maxAttempts: 3, timeout: 300, cronJobUid: job.uid },
|
|
209
|
-
);
|
|
210
|
-
} else {
|
|
211
|
-
enqueue(
|
|
212
|
-
'cron_run',
|
|
213
|
-
{
|
|
214
|
-
cronJobUid: job.uid,
|
|
215
|
-
prompt: job.prompt,
|
|
216
|
-
channelId: job.target_channel_id,
|
|
217
|
-
agentId: job.agent_id,
|
|
218
|
-
} satisfies CronRunPayload,
|
|
219
|
-
{
|
|
220
|
-
maxAttempts: config.birds.maxAttempts,
|
|
221
|
-
timeout: 600,
|
|
222
|
-
cronJobUid: job.uid,
|
|
223
|
-
},
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
updateCronJobStatus(job.uid, 'triggered', job.failure_count);
|
|
228
|
-
broadcastSSE('invalidate', { resource: 'birds', cronJobUid: job.uid });
|
|
229
|
-
}
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
tick();
|
|
233
|
-
const timer = setInterval(tick, TICK_INTERVAL_MS);
|
|
234
|
-
|
|
235
|
-
signal.addEventListener('abort', () => {
|
|
236
|
-
clearInterval(timer);
|
|
237
|
-
scheduleCache.clear();
|
|
238
|
-
scheduleErrors.clear();
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
logger.info('bird scheduler started (60s tick)');
|
|
242
|
-
}
|
package/src/daemon.ts
DELETED
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
/** @fileoverview Main orchestrator process: starts all subsystems and handles graceful shutdown. */
|
|
2
|
-
|
|
3
|
-
import { logger } from './core/logger.ts';
|
|
4
|
-
import { BANNER } from './cli/banner.ts';
|
|
5
|
-
import { loadConfig, loadDotEnv, hasSlackTokens } from './config.ts';
|
|
6
|
-
import { openDatabase, closeDatabase, setSetting, resolveDbPath } from './db/index.ts';
|
|
7
|
-
import { startWorker } from './jobs.ts';
|
|
8
|
-
import { startScheduler } from './cron/scheduler.ts';
|
|
9
|
-
import { createSlackChannel } from './channel/slack.ts';
|
|
10
|
-
import { createWebServer } from './server/index.ts';
|
|
11
|
-
import type { WebServerDeps } from './server/index.ts';
|
|
12
|
-
import { startHealthChecks, getServiceHealth } from './server/health.ts';
|
|
13
|
-
import type { Config } from './core/types.ts';
|
|
14
|
-
import type { ChannelHandle } from './channel/types.ts';
|
|
15
|
-
import { resolve } from 'node:path';
|
|
16
|
-
|
|
17
|
-
const controller = new AbortController();
|
|
18
|
-
|
|
19
|
-
const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
20
|
-
|
|
21
|
-
function setupShutdown(): void {
|
|
22
|
-
let shutting = false;
|
|
23
|
-
|
|
24
|
-
const shutdown = (signal: string) => {
|
|
25
|
-
if (shutting) {
|
|
26
|
-
logger.info(`received ${signal} again, forcing exit`);
|
|
27
|
-
process.exit(1);
|
|
28
|
-
}
|
|
29
|
-
shutting = true;
|
|
30
|
-
logger.info(`received ${signal}, shutting down...`);
|
|
31
|
-
controller.abort();
|
|
32
|
-
|
|
33
|
-
setTimeout(() => {
|
|
34
|
-
logger.warn('graceful shutdown timed out, forcing exit');
|
|
35
|
-
process.exit(1);
|
|
36
|
-
}, SHUTDOWN_TIMEOUT_MS).unref();
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
40
|
-
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
interface DaemonOptions {
|
|
44
|
-
flags: { verbose: boolean; config?: string; db?: string };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const stubDeps: WebServerDeps = {
|
|
48
|
-
slackConnected: () => false,
|
|
49
|
-
activeProcessCount: () => 0,
|
|
50
|
-
serviceHealth: () => ({ agent: { available: false }, browser: { connected: false } }),
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
export async function startDaemon(options: DaemonOptions): Promise<void> {
|
|
54
|
-
setupShutdown();
|
|
55
|
-
process.stderr.write(BANNER + '\n\n');
|
|
56
|
-
|
|
57
|
-
if (options.flags.verbose) {
|
|
58
|
-
logger.setLevel('debug');
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const configPath = resolve(
|
|
62
|
-
options.flags.config ?? process.env['BROWSERBIRD_CONFIG'] ?? 'browserbird.json',
|
|
63
|
-
);
|
|
64
|
-
const envPath = resolve('.env');
|
|
65
|
-
const dbPath = resolveDbPath(options.flags.db);
|
|
66
|
-
openDatabase(dbPath);
|
|
67
|
-
startWorker(controller.signal);
|
|
68
|
-
|
|
69
|
-
loadDotEnv(envPath);
|
|
70
|
-
let currentConfig: Config = loadConfig(configPath);
|
|
71
|
-
let slackHandle: ChannelHandle | null = null;
|
|
72
|
-
let setupMode = true;
|
|
73
|
-
|
|
74
|
-
const getConfig = (): Config => currentConfig;
|
|
75
|
-
const getDeps = (): WebServerDeps => {
|
|
76
|
-
if (setupMode) return stubDeps;
|
|
77
|
-
return {
|
|
78
|
-
slackConnected: () => slackHandle?.isConnected() ?? false,
|
|
79
|
-
activeProcessCount: () => slackHandle?.activeCount() ?? 0,
|
|
80
|
-
serviceHealth: () => getServiceHealth(currentConfig),
|
|
81
|
-
};
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const startFull = (config: Config) => {
|
|
85
|
-
currentConfig = config;
|
|
86
|
-
setupMode = false;
|
|
87
|
-
|
|
88
|
-
logger.info('connecting to slack...');
|
|
89
|
-
slackHandle = createSlackChannel(config, controller.signal);
|
|
90
|
-
|
|
91
|
-
logger.info('starting scheduler...');
|
|
92
|
-
startScheduler(config, controller.signal, {
|
|
93
|
-
postToSlack: (channel, text, opts) => slackHandle!.postMessage(channel, text, opts),
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
slackHandle.start().catch((err: unknown) => {
|
|
97
|
-
logger.error(`slack failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
startHealthChecks(config, controller.signal);
|
|
101
|
-
|
|
102
|
-
logger.success('browserbird orchestrator started');
|
|
103
|
-
logger.info(`agents: ${config.agents.map((a) => a.id).join(', ')}`);
|
|
104
|
-
logger.info(`max concurrent sessions: ${config.sessions.maxConcurrent}`);
|
|
105
|
-
if (config.browser.enabled) {
|
|
106
|
-
logger.info(`browser mode: ${process.env['BROWSER_MODE'] ?? 'persistent'}`);
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const onLaunch = async () => {
|
|
111
|
-
loadDotEnv(envPath);
|
|
112
|
-
const config = loadConfig(configPath);
|
|
113
|
-
|
|
114
|
-
if (!config.slack.botToken || !config.slack.appToken) {
|
|
115
|
-
throw new Error('Slack tokens are required to launch');
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
startFull(config);
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
const reloadConfig = (): void => {
|
|
122
|
-
loadDotEnv(envPath);
|
|
123
|
-
currentConfig = loadConfig(configPath);
|
|
124
|
-
logger.info('config reloaded');
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
if (hasSlackTokens(configPath)) {
|
|
128
|
-
if (!currentConfig.slack.botToken || !currentConfig.slack.appToken) {
|
|
129
|
-
throw new Error(
|
|
130
|
-
'slack tokens not resolvable (set SLACK_BOT_TOKEN/SLACK_APP_TOKEN or configure in browserbird.json)',
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
setSetting('onboarding_completed', 'true');
|
|
135
|
-
startFull(currentConfig);
|
|
136
|
-
} else {
|
|
137
|
-
setSetting('onboarding_completed', '');
|
|
138
|
-
logger.info('starting in setup mode (onboarding not completed)');
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
let webServer: Awaited<ReturnType<typeof createWebServer>> | null = null;
|
|
142
|
-
const webConfig = getConfig();
|
|
143
|
-
if (webConfig.web.enabled) {
|
|
144
|
-
logger.info(`starting web server on port ${webConfig.web.port}...`);
|
|
145
|
-
webServer = createWebServer(getConfig, controller.signal, getDeps, {
|
|
146
|
-
configPath,
|
|
147
|
-
onLaunch,
|
|
148
|
-
onConfigReload: reloadConfig,
|
|
149
|
-
});
|
|
150
|
-
await webServer.start();
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (setupMode) {
|
|
154
|
-
logger.info('waiting for onboarding to complete via web UI');
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
await new Promise<void>((resolvePromise) => {
|
|
158
|
-
controller.signal.addEventListener('abort', () => {
|
|
159
|
-
resolvePromise();
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
if (webServer) await webServer.stop();
|
|
164
|
-
if (slackHandle as ChannelHandle | null) {
|
|
165
|
-
await Promise.race([slackHandle!.stop(), new Promise<void>((r) => setTimeout(r, 3000))]);
|
|
166
|
-
}
|
|
167
|
-
closeDatabase();
|
|
168
|
-
logger.info('browserbird stopped');
|
|
169
|
-
}
|
package/src/db/auth.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
/** @fileoverview User and settings persistence for the auth system. */
|
|
2
|
-
|
|
3
|
-
import { getDb } from './core.ts';
|
|
4
|
-
|
|
5
|
-
export interface UserRow {
|
|
6
|
-
id: number;
|
|
7
|
-
email: string;
|
|
8
|
-
password_hash: string;
|
|
9
|
-
token_key: string;
|
|
10
|
-
created_at: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function getUserCount(): number {
|
|
14
|
-
const row = getDb().prepare('SELECT COUNT(*) as count FROM users').get() as unknown as {
|
|
15
|
-
count: number;
|
|
16
|
-
};
|
|
17
|
-
return row.count;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function getUserByEmail(email: string): UserRow | undefined {
|
|
21
|
-
return getDb().prepare('SELECT * FROM users WHERE email = ?').get(email) as unknown as
|
|
22
|
-
| UserRow
|
|
23
|
-
| undefined;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function getUserById(id: number): UserRow | undefined {
|
|
27
|
-
return getDb().prepare('SELECT * FROM users WHERE id = ?').get(id) as unknown as
|
|
28
|
-
| UserRow
|
|
29
|
-
| undefined;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function createUser(email: string, passwordHash: string, tokenKey: string): UserRow {
|
|
33
|
-
getDb()
|
|
34
|
-
.prepare('INSERT INTO users (email, password_hash, token_key) VALUES (?, ?, ?)')
|
|
35
|
-
.run(email, passwordHash, tokenKey);
|
|
36
|
-
|
|
37
|
-
return getUserByEmail(email)!;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function getSetting(key: string): string | undefined {
|
|
41
|
-
const row = getDb().prepare('SELECT value FROM settings WHERE key = ?').get(key) as unknown as
|
|
42
|
-
| { value: string }
|
|
43
|
-
| undefined;
|
|
44
|
-
return row?.value;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function setSetting(key: string, value: string): void {
|
|
48
|
-
getDb().prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value);
|
|
49
|
-
}
|