diff --git a/AGENTS.md b/AGENTS.md index 548df43..9ea4145 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -260,22 +260,15 @@ wsl --shutdown This is a one-time setup for mixed environments only. It is **not required** for single-platform contributors. -### Keeping issues.jsonl in sync (temporary) +### How the read path works -Until `beadboard-550` (direct mysql2 connection) is implemented, `issues.jsonl` must be exported manually after `bd` writes in mixed environments: +BeadBoard (`src/lib/read-issues.ts`) queries Dolt SQL directly via `mysql2` (`src/lib/dolt-client.ts`). On every page load or SSE-triggered refresh: -```bash -# In WSL2 — re-export Dolt state to issues.jsonl so Windows frontend sees it -bd list --all --limit 0 --json | python3 -c " -import sys, json -issues = json.load(sys.stdin) -with open('.beads/issues.jsonl', 'w') as f: - for issue in issues: - f.write(json.dumps(issue) + '\n') -print(f'Exported {len(issues)} issues to issues.jsonl') -" -``` +1. `readIssuesFromDisk()` → tries `readIssuesViaDolt(projectRoot)` first +2. If Dolt unreachable → logs a warning and falls back to reading `issues.jsonl` -Once `beadboard-550` ships, `issues.jsonl` becomes a deprecated fallback and this step is no longer needed. +`issues.jsonl` is a **deprecated fallback** — no manual export step is required. The file is kept on disk by `bd` for git history, but BeadBoard does not rely on it when the Dolt server is running. + +**SSE real-time updates**: `bd` touches `.beads/last-touched` on every write. Chokidar detects this change, triggers a snapshot diff, and fires an SSE event if anything changed — fetching fresh data from Dolt automatically. diff --git a/src/lib/read-issues-dolt.ts b/src/lib/read-issues-dolt.ts new file mode 100644 index 0000000..97358f8 --- /dev/null +++ b/src/lib/read-issues-dolt.ts @@ -0,0 +1,120 @@ +import type { RowDataPacket } from 'mysql2'; + +import { getDoltConnection, DoltConnectionError } from './dolt-client'; +import type { ReadIssuesOptions } from './read-issues'; +import type { BeadDependency, BeadIssue, BeadStatus } from './types'; + +interface IssueRow extends RowDataPacket { + id: string; + title: string; + description: string | null; + status: string; + priority: number; + issue_type: string; + assignee: string | null; + owner: string | null; + close_reason: string | null; + closed_by_session: string | null; + created_by: string | null; + estimated_minutes: number | null; + external_ref: string | null; + metadata: Record | null; + created_at: Date | string | null; + updated_at: Date | string | null; + closed_at: Date | string | null; + due_at: Date | string | null; + labels_concat: string | null; +} + +interface DepRow extends RowDataPacket { + issue_id: string; + depends_on_id: string; + type: string; +} + +function toIsoString(val: Date | string | null | undefined): string | null { + if (val === null || val === undefined) return null; + if (val instanceof Date) return val.toISOString(); + return val; +} + +function normalizeRow(row: IssueRow, deps: BeadDependency[]): BeadIssue { + return { + id: row.id, + title: row.title, + description: row.description ?? null, + status: (row.status as BeadStatus) ?? 'open', + priority: typeof row.priority === 'number' ? row.priority : 2, + issue_type: row.issue_type ?? 'task', + assignee: row.assignee ?? null, + templateId: null, + owner: row.owner ?? null, + labels: row.labels_concat ? row.labels_concat.split(',').filter(Boolean) : [], + dependencies: deps, + created_at: toIsoString(row.created_at) ?? '', + updated_at: toIsoString(row.updated_at) ?? '', + closed_at: toIsoString(row.closed_at), + close_reason: row.close_reason ?? null, + closed_by_session: row.closed_by_session ?? null, + created_by: row.created_by ?? null, + due_at: toIsoString(row.due_at), + estimated_minutes: typeof row.estimated_minutes === 'number' ? row.estimated_minutes : null, + external_ref: row.external_ref ?? null, + metadata: row.metadata ?? {}, + }; +} + +/** + * Query Dolt SQL server directly for all issues, joining labels and dependencies. + * Returns null (never throws) if Dolt is unreachable so the caller can fall back + * to the issues.jsonl path. + */ +export async function readIssuesViaDolt( + projectRoot: string, + options: ReadIssuesOptions = {} +): Promise { + let pool; + try { + pool = await getDoltConnection(projectRoot); + } catch (err) { + if (err instanceof DoltConnectionError) { + return null; + } + return null; + } + + try { + // Query 1: All issues with comma-separated labels (GROUP_CONCAT avoids N+1) + const [issueRows] = await pool.execute( + `SELECT i.*, GROUP_CONCAT(l.label SEPARATOR ',') AS labels_concat + FROM issues i + LEFT JOIN labels l ON l.issue_id = i.id + GROUP BY i.id` + ); + + // Query 2: All dependencies in one shot + const [depRows] = await pool.execute( + `SELECT issue_id, depends_on_id, type FROM dependencies` + ); + + // Build issue_id → BeadDependency[] lookup + const depsMap = new Map(); + for (const dep of depRows) { + const depType: BeadDependency['type'] = + dep.type === 'parent-child' ? 'parent' : (dep.type as BeadDependency['type']); + const list = depsMap.get(dep.issue_id) ?? []; + list.push({ type: depType, target: dep.depends_on_id }); + depsMap.set(dep.issue_id, list); + } + + return issueRows + .map((row) => normalizeRow(row, depsMap.get(row.id) ?? [])) + .filter((issue) => { + if (issue.status === 'tombstone' && !options.includeTombstones) return false; + if (issue.labels.includes('gt:agent') && !options.skipAgentFilter) return false; + return true; + }); + } catch { + return null; + } +} diff --git a/src/lib/read-issues.ts b/src/lib/read-issues.ts index e74ffd6..3fc975b 100644 --- a/src/lib/read-issues.ts +++ b/src/lib/read-issues.ts @@ -1,161 +1,69 @@ -import path from 'node:path'; - -import { runBdCommand } from './bridge'; -import { parseIssuesJsonl } from './parser'; -import { canonicalizeWindowsPath } from './pathing'; -import { readTextFileWithRetry } from './read-text-retry'; -import { buildProjectContext } from './project-context'; -import type { BeadDependency, BeadIssue } from './types'; -import type { BeadIssueWithProject, ProjectSource } from './types'; - -export interface ReadIssuesOptions { - projectRoot?: string; - includeTombstones?: boolean; - projectSource?: ProjectSource; - projectAddedAt?: string | null; - preferBd?: boolean; - skipAgentFilter?: boolean; -} - -export function resolveIssuesJsonlPathCandidates(projectRoot: string = process.cwd()): string[] { - const baseDir = path.resolve(projectRoot, '.beads'); - const primary = canonicalizeWindowsPath(path.join(baseDir, 'issues.jsonl')); - const fallback = canonicalizeWindowsPath(path.join(baseDir, 'issues.jsonl.new')); - return [primary, fallback]; -} - -export function resolveIssuesJsonlPath(projectRoot: string = process.cwd()): string { - return resolveIssuesJsonlPathCandidates(projectRoot)[0]; -} - -function normalizeDependencies(value: unknown): BeadDependency[] { - if (!Array.isArray(value)) { - return []; - } - - return value - .map((item) => { - if (!item || typeof item !== 'object') { - return null; - } - const dep = item as { type?: unknown; target?: unknown; depends_on_id?: unknown }; - if (typeof dep.type !== 'string') { - return null; - } - const target = typeof dep.target === 'string' ? dep.target : typeof dep.depends_on_id === 'string' ? dep.depends_on_id : null; - if (!target) { - return null; - } - return { - type: dep.type === 'parent-child' ? 'parent' : (dep.type as BeadDependency['type']), - target, - }; - }) - .filter((dep): dep is BeadDependency => dep !== null); -} - -function normalizeBdIssue(raw: unknown): BeadIssue | null { - if (!raw || typeof raw !== 'object') { - return null; - } - const data = raw as Record; - if (typeof data.id !== 'string' || typeof data.title !== 'string') { - return null; - } - return { - id: data.id, - title: data.title, - description: typeof data.description === 'string' ? data.description : null, - status: typeof data.status === 'string' ? (data.status as BeadIssue['status']) : 'open', - priority: typeof data.priority === 'number' ? data.priority : 2, - issue_type: typeof data.issue_type === 'string' ? data.issue_type : 'task', - assignee: typeof data.assignee === 'string' ? data.assignee : null, - templateId: null, - owner: typeof data.owner === 'string' ? data.owner : null, - labels: Array.isArray(data.labels) ? data.labels.filter((x): x is string => typeof x === 'string') : [], - dependencies: normalizeDependencies(data.dependencies), - created_at: typeof data.created_at === 'string' ? data.created_at : '', - updated_at: typeof data.updated_at === 'string' ? data.updated_at : '', - closed_at: typeof data.closed_at === 'string' ? data.closed_at : null, - close_reason: typeof data.close_reason === 'string' ? data.close_reason : null, - closed_by_session: typeof data.closed_by_session === 'string' ? data.closed_by_session : null, - created_by: typeof data.created_by === 'string' ? data.created_by : null, - due_at: typeof data.due_at === 'string' ? data.due_at : null, - estimated_minutes: typeof data.estimated_minutes === 'number' ? data.estimated_minutes : null, - external_ref: typeof data.external_ref === 'string' ? data.external_ref : null, - metadata: typeof data.metadata === 'object' && data.metadata !== null ? (data.metadata as Record) : {}, - }; -} - -async function readIssuesViaBd(options: ReadIssuesOptions, project: ReturnType): Promise { - const projectRoot = options.projectRoot ?? process.cwd(); - const command = await runBdCommand({ - projectRoot, - args: ['list', '--all', '--limit', '0', '--json'], - }); - - if (!command.success) { - return null; - } - - try { - const parsed = JSON.parse(command.stdout) as unknown; - if (!Array.isArray(parsed)) { - return null; - } - - return parsed - .map((issue) => normalizeBdIssue(issue)) - .filter((issue): issue is BeadIssue => issue !== null) - .filter((issue) => { - // Exclude tombstones - if (issue.status === 'tombstone' && !options.includeTombstones) return false; - // Exclude agent identities from mission lists unless skipping filter (for watcher/diffing) - if (issue.labels.includes('gt:agent') && !options.skipAgentFilter) return false; - return true; - }) - .map((issue) => ({ - ...issue, - project, - })); - } catch { - return null; - } -} - -export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promise { - const projectRoot = options.projectRoot ?? process.cwd(); - const candidates = resolveIssuesJsonlPathCandidates(projectRoot); - const project = buildProjectContext(projectRoot, { - source: options.projectSource ?? 'local', - addedAt: options.projectAddedAt ?? null, - }); - - if (options.preferBd ?? false) { - const viaBd = await readIssuesViaBd(options, project); - if (viaBd) { - return viaBd; - } - } - - for (const issuesPath of candidates) { - try { - const jsonl = await readTextFileWithRetry(issuesPath); - return parseIssuesJsonl(jsonl, { - includeTombstones: options.includeTombstones ?? false, - skipAgentFilter: options.skipAgentFilter ?? false, - }).map((issue) => ({ - ...issue, - project, - })); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - continue; - } - - throw error; - } - } - - return []; -} +import path from 'node:path'; + +import { parseIssuesJsonl } from './parser'; +import { canonicalizeWindowsPath } from './pathing'; +import { readTextFileWithRetry } from './read-text-retry'; +import { buildProjectContext } from './project-context'; +import { readIssuesViaDolt } from './read-issues-dolt'; +import type { BeadIssueWithProject, ProjectSource } from './types'; + +export interface ReadIssuesOptions { + projectRoot?: string; + includeTombstones?: boolean; + projectSource?: ProjectSource; + projectAddedAt?: string | null; + preferBd?: boolean; + skipAgentFilter?: boolean; +} + +export function resolveIssuesJsonlPathCandidates(projectRoot: string = process.cwd()): string[] { + const baseDir = path.resolve(projectRoot, '.beads'); + const primary = canonicalizeWindowsPath(path.join(baseDir, 'issues.jsonl')); + const fallback = canonicalizeWindowsPath(path.join(baseDir, 'issues.jsonl.new')); + return [primary, fallback]; +} + +export function resolveIssuesJsonlPath(projectRoot: string = process.cwd()): string { + return resolveIssuesJsonlPathCandidates(projectRoot)[0]; +} + + + +export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promise { + const projectRoot = options.projectRoot ?? process.cwd(); + const candidates = resolveIssuesJsonlPathCandidates(projectRoot); + const project = buildProjectContext(projectRoot, { + source: options.projectSource ?? 'local', + addedAt: options.projectAddedAt ?? null, + }); + + // Try Dolt SQL first (always preferred when server is available) + const viaDolt = await readIssuesViaDolt(projectRoot, options); + if (viaDolt !== null) { + return viaDolt.map((issue) => ({ ...issue, project })); + } + + // Dolt unreachable — fall back to issues.jsonl + console.warn('[beadboard] Dolt unreachable, falling back to issues.jsonl (data may be stale)'); + + for (const issuesPath of candidates) { + try { + const jsonl = await readTextFileWithRetry(issuesPath); + return parseIssuesJsonl(jsonl, { + includeTombstones: options.includeTombstones ?? false, + skipAgentFilter: options.skipAgentFilter ?? false, + }).map((issue) => ({ + ...issue, + project, + })); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + continue; + } + + throw error; + } + } + + return []; +}