Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/cli/bot-process.ts
Original file line number Diff line number Diff line change
@@ -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<BotStatus> {
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}` };
}
}
49 changes: 49 additions & 0 deletions src/cli/dashboard-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({}));
Expand Down
30 changes: 26 additions & 4 deletions src/cli/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[];
Expand All @@ -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 };
}

Expand Down Expand Up @@ -78,6 +79,14 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } =
}).join('') : '<tr><td colspan="4" class="dim">No runs yet. A verified-pr schedule produces a 🧾 receipt.</td></tr>';
const runsPanel = `<div class="panel"><h2>Run history &amp; receipts</h2><table><thead><tr><th>schedule</th><th>when</th><th>status</th><th>receipt</th></tr></thead><tbody>${runRows}</tbody></table></div>`;

// Bot lifecycle: status + start/stop (it still needs a token + allowlist in config to connect).
const botCtl = live
? (d.bot.running
? `<button class="danger" onclick="act('bot.stop',{})">Stop</button>`
: `<button onclick="act('bot.start',{})">Start</button>`)
: `<span class="dim">${d.bot.running ? 'running' : 'stopped'}</span>`;
const botPanel = `<div class="panel"><div class="ctl"><span>Telegram / Discord / Slack bot — <b class="${d.bot.running ? 'ok' : 'dim'}">${d.bot.running ? `running (pid ${d.bot.pid})` : 'stopped'}</b></span>${botCtl}</div></div>`;

// Model switcher (live: a dropdown of known models; read-only: just the current one).
const modelCtl = live && d.models.length
? `<select onchange="act('model.set',{model:this.value})">${d.models.map(m => `<option${m === d.model ? ' selected' : ''}>${esc(m)}</option>`).join('')}</select>`
Expand All @@ -92,7 +101,15 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } =
<td class="mono dim">${esc(p.baseUrl || '—')}</td>
<td>${p.models.length ? p.models.map(esc).join('<br>') : '<span class="dim">auto-discover</span>'}</td>
<td>${p.keyEnv ? (p.keySet ? `<span class="ok">✓ ${esc(p.keyEnv)}</span>` : `<span class="warn">set ${esc(p.keyEnv)}</span>`) : '<span class="dim">local</span>'}</td>
${live ? `<td class="r">${p.baseUrl ? `<button onclick="act('provider.test',{name:'${esc(p.name)}'})">Test</button>` : ''}${p.custom ? ` <button class="danger" onclick="if(confirm('Remove ${esc(p.name)}?'))act('provider.remove',{name:'${esc(p.name)}'})">Remove</button>` : ''}</td>` : ''}
</tr>`).join('');
const providerAddForm = live ? `<div class="addform">
<input id="p_name" placeholder="name (e.g. openrouter)">
<input id="p_url" placeholder="base URL (https://…/v1)" style="flex:2">
<input id="p_key" placeholder="key env var (OPENROUTER_API_KEY)">
<input id="p_model" placeholder="model id (optional)">
<button onclick="act('provider.add',{name:p_name.value,baseUrl:p_url.value,keyEnv:p_key.value,model:p_model.value})">Add &amp; test</button>
</div><p class="dim" style="margin:8px 0 0">The key itself goes in <span class="mono">~/.qodex/.env</span> as the named env var — never here.</p>` : '';
const sessionRows = d.sessions.map(s => `<tr>
<td class="mono dim">${esc(s.id.slice(0, 8))}</td><td>${esc(s.title)}</td>
<td class="mono dim">${esc(s.model)}</td><td class="r">${s.turns}</td>
Expand Down Expand Up @@ -152,7 +169,8 @@ export function buildDashboardHtml(d: DashboardData, opts: { token?: string } =
<div class="two">${controlPanel}</div>
${schedulePanel}
${runsPanel}
<div class="panel"><h2>Providers &amp; models</h2><table><thead><tr><th>Provider</th><th>Base URL</th><th>Models</th><th>API key</th></tr></thead><tbody>${providerRows}</tbody></table></div>
<div class="panel"><h2>Providers &amp; models</h2><table><thead><tr><th>Provider</th><th>Base URL</th><th>Models</th><th>API key</th>${live ? '<th class="r">test / remove</th>' : ''}</tr></thead><tbody>${providerRows}</tbody></table>${providerAddForm}</div>
${botPanel}
<div class="panel"><h2>Recent sessions</h2><table><thead><tr><th>id</th><th>title</th><th>model</th><th class="r">turns</th><th class="r">tokens</th><th class="r">cost</th><th>when</th></tr></thead><tbody>${sessionRows}</tbody></table></div>
<div class="two">
<div class="panel"><h2>Memory · learned facts</h2><ul>${factList}</ul>${live ? `<div class="ctl" style="border:0;padding-top:10px"><input id="newfact" placeholder="Remember a fact about this project…" style="flex:1;margin-right:8px;background:#1b2233;border:1px solid var(--line);border-radius:8px;padding:6px 10px;color:var(--ink)"><button onclick="const i=document.getElementById('newfact');if(i.value.trim())act('memory.add',{fact:i.value.trim()})">Remember</button></div>` : ''}</div>
Expand Down Expand Up @@ -210,7 +228,7 @@ export async function gatherDashboardData(cwd: string): Promise<DashboardData> {
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,
});
}

Expand Down Expand Up @@ -270,10 +288,14 @@ export async function gatherDashboardData(cwd: string): Promise<DashboardData> {
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,
Expand Down
35 changes: 35 additions & 0 deletions src/setup/provider-test.ts
Original file line number Diff line number Diff line change
@@ -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<ProbeResult> {
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') };
}
}
6 changes: 5 additions & 1 deletion test/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};

Expand All @@ -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');
Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions test/provider-bot-control.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});