diff --git a/src/cli/bot-process.ts b/src/cli/bot-process.ts new file mode 100644 index 0000000..ab4dd36 --- /dev/null +++ b/src/cli/bot-process.ts @@ -0,0 +1,65 @@ +/** + * Bot process lifecycle — start/stop the Telegram/Discord/Slack bot as a detached background + * process, tracked by a PID file, so the dashboard can run it without holding the terminal. + * + * The bot is `qodex bot` (deny-by-default auth, tokens from ~/.qodex/.env). We spawn it detached + * and record its PID; stop sends SIGTERM; status checks liveness with `kill(pid, 0)`. + * + * pidFilePath / parsePid are PURE; the rest is best-effort process I/O. + */ +import { spawn } from 'cross-spawn'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { QODEX_HOME } from '../config/defaults.js'; + +export function pidFilePath(): string { return path.join(QODEX_HOME, 'bot.pid'); } + +/** Parse a PID from a pidfile body. PURE. */ +export function parsePid(raw: string | null | undefined): number | null { + const n = parseInt(String(raw ?? '').trim(), 10); + return Number.isInteger(n) && n > 0 ? n : null; +} + +/** Is a pid alive? (kill 0 probes without signalling.) */ +export function isAlive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch (e: any) { return e?.code === 'EPERM'; } +} + +export interface BotStatus { running: boolean; pid?: number } + +export async function botStatus(): Promise { + const pid = parsePid(await fs.readFile(pidFilePath(), 'utf-8').catch(() => null)); + if (pid && isAlive(pid)) return { running: true, pid }; + return { running: false }; +} + +function resolveCliPath(): string { + return process.env.QODEX_CLI_PATH || 'qodex'; +} + +export async function startBot(cwd: string): Promise<{ ok: boolean; message: string }> { + const cur = await botStatus(); + if (cur.running) return { ok: false, message: `Bot already running (pid ${cur.pid}).` }; + try { + await fs.mkdir(QODEX_HOME, { recursive: true }); + const child = spawn(resolveCliPath(), ['bot'], { cwd, detached: true, stdio: 'ignore' }); + child.unref(); + if (!child.pid) return { ok: false, message: 'Could not spawn the bot process.' }; + await fs.writeFile(pidFilePath(), String(child.pid), 'utf-8'); + return { ok: true, message: `Bot started (pid ${child.pid}). It needs a token + allowlist in config to actually connect.` }; + } catch (e: any) { + return { ok: false, message: `Failed to start bot: ${e?.message ?? e}` }; + } +} + +export async function stopBot(): Promise<{ ok: boolean; message: string }> { + const cur = await botStatus(); + if (!cur.running || !cur.pid) { await fs.unlink(pidFilePath()).catch(() => {}); return { ok: false, message: 'Bot is not running.' }; } + try { + process.kill(cur.pid, 'SIGTERM'); + await fs.unlink(pidFilePath()).catch(() => {}); + return { ok: true, message: `Stopped the bot (pid ${cur.pid}).` }; + } catch (e: any) { + return { ok: false, message: `Couldn't stop pid ${cur.pid}: ${e?.message ?? e}` }; + } +} diff --git a/src/cli/dashboard-control.ts b/src/cli/dashboard-control.ts index f50678e..56c5557 100644 --- a/src/cli/dashboard-control.ts +++ b/src/cli/dashboard-control.ts @@ -147,6 +147,55 @@ export async function dispatchAction(name: string, params: any, cwd: string): Pr const ok = getScheduleStore().remove(String(params?.id ?? '')); return ok ? { ok: true, message: 'Schedule removed.' } : { ok: false, message: 'No such schedule.' }; } + case 'provider.add': { + const name = String(params?.name ?? '').trim(); + const baseUrl = String(params?.baseUrl ?? '').trim(); + const keyEnv = String(params?.keyEnv ?? '').trim(); + if (!name || !baseUrl || !keyEnv) return { ok: false, message: 'Need a name, base URL, and key-env var.' }; + try { + const { buildCustomEntry } = await import('../setup/gateways.js'); + const { addProviderToConfig } = await import('../setup/provider-writer.js'); + const entry = buildCustomEntry({ name, baseUrl, apiKeyEnv: keyEnv, modelId: params?.model ? String(params.model) : undefined }); + await addProviderToConfig(entry, params?.model ? { defaultModel: String(params.model) } : {}); + const { probeProvider } = await import('../setup/provider-test.js'); + const test = await probeProvider({ baseUrl, keyEnv }); + return { ok: true, message: `Added "${name}". Test: ${test.ok ? '✓ ' : '✗ '}${test.detail}` }; + } catch (e: any) { return { ok: false, message: `Add failed: ${e?.message ?? e}` }; } + } + case 'provider.test': { + const name = String(params?.name ?? '').trim(); + const { loadConfig } = await import('../config/loader.js'); + const cfg: any = await loadConfig(cwd).catch(() => ({})); + const builtin = cfg?.providers?.[name]; + const custom = (cfg?.providers?.custom ?? []).find((c: any) => c?.name === name); + const p = custom ?? builtin; + if (!p) return { ok: false, message: `No provider "${name}".` }; + const { probeProvider } = await import('../setup/provider-test.js'); + const r = await probeProvider({ baseUrl: p.baseUrl, keyEnv: p.apiKeyEnv }); + return { ok: r.ok, message: `${name}: ${r.ok ? '✓ ' : '✗ '}${r.detail}` }; + } + case 'provider.remove': { + const name = String(params?.name ?? '').trim(); + const fs = await import('fs/promises'); + const yaml = await import('js-yaml'); + const { QODEX_CONFIG_FILE } = await import('../config/defaults.js'); + let raw = ''; try { raw = await fs.readFile(QODEX_CONFIG_FILE, 'utf-8'); } catch { return { ok: false, message: 'No config file.' }; } + const cfg: any = raw.trim() ? (yaml.load(raw) ?? {}) : {}; + const custom = cfg?.providers?.custom; + if (!Array.isArray(custom) || !custom.some((c: any) => c?.name === name)) return { ok: false, message: `No custom provider "${name}".` }; + cfg.providers.custom = custom.filter((c: any) => c?.name !== name); + const { writeFileAtomic } = await import('../utils/atomic-write.js'); + await writeFileAtomic(QODEX_CONFIG_FILE, yaml.dump(cfg, { lineWidth: 100, noRefs: true })); + return { ok: true, message: `Removed "${name}".` }; + } + case 'bot.start': { + const { startBot } = await import('./bot-process.js'); + return startBot(cwd); + } + case 'bot.stop': { + const { stopBot } = await import('./bot-process.js'); + return stopBot(); + } case 'offload.apply': { const { loadConfig } = await import('../config/loader.js'); const cfg: any = await loadConfig(cwd).catch(() => ({})); diff --git a/src/cli/dashboard.ts b/src/cli/dashboard.ts index 13ff06d..e463bba 100644 --- a/src/cli/dashboard.ts +++ b/src/cli/dashboard.ts @@ -12,7 +12,7 @@ export interface DashboardData { project: string; model: string; generatedAt: string; - providers: { name: string; baseUrl: string; keyEnv?: string; keySet?: boolean; models: string[]; isDefault: boolean }[]; + providers: { name: string; baseUrl: string; keyEnv?: string; keySet?: boolean; models: string[]; isDefault: boolean; custom?: boolean }[]; sessions: { id: string; title: string; model: string; turns: number; tokens: number; cost: number; when: string }[]; facts: string[]; episodes: { when: string; prompt: string; summary: string }[]; @@ -22,6 +22,7 @@ export interface DashboardData { models: string[]; candidates: { name: string; description: string; confidence?: number }[]; runs: { schedule: string; when: string; status: string; receipt?: { status: string; prUrl?: string; verification?: { command: string; passed: boolean }[] } }[]; + bot: { running: boolean; pid?: number }; totals: { sessions: number; tokens: number; cost: number; facts: number; episodes: number; skills: number }; } @@ -78,6 +79,14 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } = }).join('') : 'No runs yet. A verified-pr schedule produces a 🧾 receipt.'; const runsPanel = `

Run history & receipts

${runRows}
schedulewhenstatusreceipt
`; + // Bot lifecycle: status + start/stop (it still needs a token + allowlist in config to connect). + const botCtl = live + ? (d.bot.running + ? `` + : ``) + : `${d.bot.running ? 'running' : 'stopped'}`; + const botPanel = `
Telegram / Discord / Slack bot — ${d.bot.running ? `running (pid ${d.bot.pid})` : 'stopped'}${botCtl}
`; + // Model switcher (live: a dropdown of known models; read-only: just the current one). const modelCtl = live && d.models.length ? `` @@ -92,7 +101,15 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } = ${esc(p.baseUrl || '—')} ${p.models.length ? p.models.map(esc).join('
') : 'auto-discover'} ${p.keyEnv ? (p.keySet ? `✓ ${esc(p.keyEnv)}` : `set ${esc(p.keyEnv)}`) : 'local'} + ${live ? `${p.baseUrl ? `` : ''}${p.custom ? ` ` : ''}` : ''} `).join(''); + const providerAddForm = live ? `
+ + + + + +

The key itself goes in ~/.qodex/.env as the named env var — never here.

` : ''; const sessionRows = d.sessions.map(s => ` ${esc(s.id.slice(0, 8))}${esc(s.title)} ${esc(s.model)}${s.turns} @@ -152,7 +169,8 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } =
${controlPanel}
${schedulePanel} ${runsPanel} -

Providers & models

${providerRows}
ProviderBase URLModelsAPI key
+

Providers & models

${live ? '' : ''}${providerRows}
ProviderBase URLModelsAPI keytest / remove
${providerAddForm}
+ ${botPanel}

Recent sessions

${sessionRows}
idtitlemodelturnstokenscostwhen

Memory · learned facts

    ${factList}
${live ? `
` : ''}
@@ -210,7 +228,7 @@ export async function gatherDashboardData(cwd: string): Promise { name: c.name, baseUrl: c.baseUrl ?? '', keyEnv: c.apiKeyEnv, keySet: c.apiKeyEnv ? !!process.env[c.apiKeyEnv] : undefined, models: (c.models ?? []).map((m: any) => m.id).filter(Boolean), - isDefault: c.name === defProvider, + isDefault: c.name === defProvider, custom: true, }); } @@ -270,10 +288,14 @@ export async function gatherDashboardData(cwd: string): Promise { return all.slice(0, 12); } catch { return []; } })(); + const bot = await (async () => { + try { const { botStatus } = await import('./bot-process.js'); return await botStatus(); } + catch { return { running: false }; } + })(); return { project, model: defModel, generatedAt: new Date().toISOString().slice(0, 16).replace('T', ' '), - providers, sessions, facts, episodes, skills, controls, schedules, models, candidates, runs, + providers, sessions, facts, episodes, skills, controls, schedules, models, candidates, runs, bot, totals: { sessions: sessions.length, tokens: sessions.reduce((a, s) => a + s.tokens, 0), cost: sessions.reduce((a, s) => a + s.cost, 0), facts: facts.length, episodes: episodes.length, skills: skills.length, diff --git a/src/setup/provider-test.ts b/src/setup/provider-test.ts new file mode 100644 index 0000000..65412e8 --- /dev/null +++ b/src/setup/provider-test.ts @@ -0,0 +1,35 @@ +/** + * Provider connectivity test — does this endpoint actually answer with the configured key? + * For OpenAI-compatible providers we GET `{baseUrl}/models`; a 200 with a model list means the + * URL + key work. Used by `qodex provider` and the dashboard's "Test" button so you find out a + * provider is misconfigured here, not mid-task. + * + * buildModelsUrl is PURE (unit-tested); probeProvider does the fetch and never throws. + */ + +/** OpenAI-compatible model-list endpoint for a base URL (which already includes /v1). PURE. */ +export function buildModelsUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, '') + '/models'; +} + +export interface ProbeResult { ok: boolean; detail: string } + +/** Probe a provider's `/models`. Reads the API key from `keyEnv` (the env var name, not the + * secret). Returns a friendly result; never throws. */ +export async function probeProvider(opts: { baseUrl: string; keyEnv?: string; timeoutMs?: number }): Promise { + if (!opts.baseUrl) return { ok: false, detail: 'no base URL' }; + const key = opts.keyEnv ? process.env[opts.keyEnv] : undefined; + if (opts.keyEnv && !key) return { ok: false, detail: `${opts.keyEnv} is not set — add it to ~/.qodex/.env` }; + try { + const res = await fetch(buildModelsUrl(opts.baseUrl), { + headers: key ? { authorization: `Bearer ${key}` } : {}, + signal: AbortSignal.timeout(opts.timeoutMs ?? 6000), + }); + if (!res.ok) return { ok: false, detail: `HTTP ${res.status}${res.status === 401 ? ' — bad/expired key' : ''}` }; + const j: any = await res.json().catch(() => null); + const n = Array.isArray(j?.data) ? j.data.length : (Array.isArray(j?.models) ? j.models.length : undefined); + return { ok: true, detail: n != null ? `reachable — ${n} model(s)` : 'reachable' }; + } catch (e: any) { + return { ok: false, detail: e?.name === 'TimeoutError' ? 'timed out — endpoint unreachable' : (e?.message ?? 'unreachable') }; + } +} diff --git a/test/dashboard.test.ts b/test/dashboard.test.ts index b188b7d..958e2a6 100644 --- a/test/dashboard.test.ts +++ b/test/dashboard.test.ts @@ -17,6 +17,7 @@ const data: DashboardData = { models: ['qwen3-coder', 'anthropic/claude-3.5'], candidates: [{ name: 'add-pagination', description: 'cursor pagination playbook', confidence: 82 }], runs: [{ schedule: 'nightly-deps', when: '3h ago', status: 'success', receipt: { status: 'opened', prUrl: 'https://h/pr/9', verification: [{ command: 'tsc', passed: true }] } }], + bot: { running: false }, totals: { sessions: 1, tokens: 42000, cost: 0.12, facts: 1, episodes: 1, skills: 1 }, }; @@ -39,7 +40,7 @@ describe('qodex dashboard (pure render)', () => { }); it('handles an empty/fresh install gracefully', () => { - const empty: DashboardData = { project: 'x', model: 'm', generatedAt: 't', providers: [], sessions: [], facts: [], episodes: [], skills: [], controls: [], schedules: [], models: [], candidates: [], runs: [], totals: { sessions: 0, tokens: 0, cost: 0, facts: 0, episodes: 0, skills: 0 } }; + const empty: DashboardData = { project: 'x', model: 'm', generatedAt: 't', providers: [], sessions: [], facts: [], episodes: [], skills: [], controls: [], schedules: [], models: [], candidates: [], runs: [], bot: { running: false }, totals: { sessions: 0, tokens: 0, cost: 0, facts: 0, episodes: 0, skills: 0 } }; const html = buildDashboardHtml(empty); expect(html).toContain('No sessions yet'); expect(html).toContain('Nothing learned yet'); @@ -63,6 +64,9 @@ describe('qodex dashboard (pure render)', () => { expect(live).toContain('Run history'); // receipts panel expect(live).toContain('🧾 opened'); // receipt verdict surfaced expect(live).toContain('https://h/pr/9'); // receipt PR link + expect(live).toContain("act('provider.add'"); // provider add form + expect(live).toContain("act('provider.test'"); // per-provider test button + expect(live).toContain("act('bot.start'"); // bot lifecycle (stopped → Start) }); // Exercises the REAL gather chain (config + store + skills, read-only) for an empty cwd — proves it diff --git a/test/provider-bot-control.test.ts b/test/provider-bot-control.test.ts new file mode 100644 index 0000000..47e782d --- /dev/null +++ b/test/provider-bot-control.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { buildModelsUrl, probeProvider } from '../src/setup/provider-test.ts'; +import { parsePid, pidFilePath } from '../src/cli/bot-process.ts'; +import { dispatchAction } from '../src/cli/dashboard-control.ts'; + +describe('provider-test', () => { + it('builds the /models URL, trimming trailing slashes', () => { + expect(buildModelsUrl('https://openrouter.ai/api/v1')).toBe('https://openrouter.ai/api/v1/models'); + expect(buildModelsUrl('http://localhost:1234/v1/')).toBe('http://localhost:1234/v1/models'); + }); + it('reports a missing key env var without hitting the network', async () => { + const r = await probeProvider({ baseUrl: 'https://x/v1', keyEnv: 'DEFINITELY_UNSET_KEY_ENV_XYZ' }); + expect(r.ok).toBe(false); + expect(r.detail).toMatch(/DEFINITELY_UNSET_KEY_ENV_XYZ.*\.env/); + }); + it('handles no base URL', async () => { + expect((await probeProvider({ baseUrl: '' })).ok).toBe(false); + }); +}); + +describe('bot-process (pure bits)', () => { + it('parses a pid, rejecting junk / non-positive', () => { + expect(parsePid('1234\n')).toBe(1234); + expect(parsePid(' 77 ')).toBe(77); + expect(parsePid('0')).toBeNull(); + expect(parsePid('-3')).toBeNull(); + expect(parsePid('nope')).toBeNull(); + expect(parsePid(null)).toBeNull(); + }); + it('pidFilePath lives under ~/.qodex', () => { + expect(pidFilePath()).toMatch(/\.qodex[/\\]bot\.pid$/); + }); +}); + +describe('dispatchAction — provider/bot validation (no side effects on the reject path)', () => { + it('provider.add needs name + baseUrl + keyEnv', async () => { + expect((await dispatchAction('provider.add', { name: 'x', baseUrl: '' }, '/tmp')).ok).toBe(false); + }); + it('provider.test / remove on an unknown name fail cleanly', async () => { + expect((await dispatchAction('provider.test', { name: 'nope-provider-xyz' }, '/tmp')).ok).toBe(false); + expect((await dispatchAction('provider.remove', { name: 'nope-provider-xyz' }, '/tmp')).ok).toBe(false); + }); +});