diff --git a/plugin/dashboard/app/api/model-provenance/route.ts b/plugin/dashboard/app/api/model-provenance/route.ts new file mode 100644 index 0000000..71978cf --- /dev/null +++ b/plugin/dashboard/app/api/model-provenance/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { + getLearningModelProvenance, + type LearningEntityType, +} from "@/lib/model-lineage"; + +const VALID_TYPES = new Set([ + "profile", + "user_playbook", + "agent_playbook", +]); + +// Local dashboard data route (same pattern as /api/sessions): server reads +// filesystem-local state and returns JSON to client components. +export async function GET(req: Request) { + const url = new URL(req.url); + const entityType = (url.searchParams.get("entityType") || "").trim(); + const entityId = (url.searchParams.get("entityId") || "").trim(); + + if (!VALID_TYPES.has(entityType as LearningEntityType)) { + return NextResponse.json( + { + error: + "entityType must be one of profile, user_playbook, agent_playbook", + }, + { status: 400 }, + ); + } + if (!entityId) { + return NextResponse.json({ error: "entityId is required" }, { status: 400 }); + } + + const provenance = getLearningModelProvenance(entityType, entityId); + return NextResponse.json({ provenance }); +} diff --git a/plugin/dashboard/app/preferences/[id]/page.tsx b/plugin/dashboard/app/preferences/[id]/page.tsx index 55149ae..b5700a0 100644 --- a/plugin/dashboard/app/preferences/[id]/page.tsx +++ b/plugin/dashboard/app/preferences/[id]/page.tsx @@ -22,6 +22,7 @@ import { } from "lucide-react"; import { PageHeader } from "@/components/common/page-header"; import { LearningHostProvenance } from "@/components/common/host-badge"; +import { LearningModelProvenanceView } from "@/components/common/model-provenance"; import { EmptyState } from "@/components/common/empty-state"; import { DeleteLearningDangerZone } from "@/components/common/delete-learning-danger-zone"; import { Button } from "@/components/ui/button"; @@ -31,6 +32,7 @@ import { Separator } from "@/components/ui/separator"; import { reflexio } from "@/lib/reflexio-client"; import { formatTimestamp, truncateId } from "@/lib/format"; import { useRequestHostAttribution } from "@/lib/host-attribution"; +import { useLearningModelProvenance } from "@/lib/model-provenance"; import { cn } from "@/lib/utils"; import { statusLabel as status } from "@/lib/status"; import type { UserProfile } from "@/lib/types"; @@ -52,6 +54,10 @@ export default function PreferenceDetailPage({ const [editing, setEditing] = useState(false); const [content, setContent] = useState(""); const attribution = useRequestHostAttribution(); + const modelProvenance = useLearningModelProvenance( + "profile", + profile?.profile_id ?? id, + ); useEffect(() => { let cancelled = false; @@ -352,6 +358,12 @@ export default function PreferenceDetailPage({ } /> )} + + } + /> {profile.source && ( )} diff --git a/plugin/dashboard/app/skills/project/[id]/page.tsx b/plugin/dashboard/app/skills/project/[id]/page.tsx index 6e96703..849b4f0 100644 --- a/plugin/dashboard/app/skills/project/[id]/page.tsx +++ b/plugin/dashboard/app/skills/project/[id]/page.tsx @@ -18,6 +18,7 @@ import { } from "lucide-react"; import { PageHeader } from "@/components/common/page-header"; import { LearningHostProvenance } from "@/components/common/host-badge"; +import { LearningModelProvenanceView } from "@/components/common/model-provenance"; import { EmptyState } from "@/components/common/empty-state"; import { DeleteLearningDangerZone } from "@/components/common/delete-learning-danger-zone"; import { Button } from "@/components/ui/button"; @@ -27,6 +28,7 @@ import { Separator } from "@/components/ui/separator"; import { reflexio } from "@/lib/reflexio-client"; import { formatTimestamp, truncateId } from "@/lib/format"; import { useRequestHostAttribution } from "@/lib/host-attribution"; +import { useLearningModelProvenance } from "@/lib/model-provenance"; import { cn } from "@/lib/utils"; import { statusLabel } from "@/lib/status"; import type { StatusLabel } from "@/lib/status"; @@ -70,6 +72,10 @@ export default function ProjectSkillDetailPage({ rationale: "", }); const attribution = useRequestHostAttribution(); + const modelProvenance = useLearningModelProvenance( + "user_playbook", + playbook?.user_playbook_id ?? id, + ); useEffect(() => { let cancelled = false; @@ -360,6 +366,12 @@ export default function ProjectSkillDetailPage({ } /> )} + + } + /> {playbook.source && ( )} diff --git a/plugin/dashboard/app/skills/shared/[id]/page.tsx b/plugin/dashboard/app/skills/shared/[id]/page.tsx index 7158b2c..4496e89 100644 --- a/plugin/dashboard/app/skills/shared/[id]/page.tsx +++ b/plugin/dashboard/app/skills/shared/[id]/page.tsx @@ -33,6 +33,8 @@ import { import { reflexio } from "@/lib/reflexio-client"; import { formatTimestamp, truncateId } from "@/lib/format"; import { cn } from "@/lib/utils"; +import { LearningModelProvenanceView } from "@/components/common/model-provenance"; +import { useLearningModelProvenance } from "@/lib/model-provenance"; import { agentPlaybookStatusLabel, statusLabel } from "@/lib/status"; import type { StatusLabel } from "@/lib/status"; import type { AgentPlaybook, AgentPlaybookStatus } from "@/lib/types"; @@ -86,6 +88,10 @@ export default function SharedSkillDetailPage({ const router = useRouter(); const [playbook, setPlaybook] = useState(null); + const modelProvenance = useLearningModelProvenance( + "agent_playbook", + playbook?.agent_playbook_id ?? id, + ); const [notFound, setNotFound] = useState(false); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); @@ -483,6 +489,12 @@ export default function SharedSkillDetailPage({ display={truncateId(playbook.playbook_metadata, 32, 8)} /> )} + + } + /> @@ -729,7 +741,7 @@ function Meta({ }: { icon?: React.ComponentType<{ className?: string }>; label: string; - value: string; + value: React.ReactNode; mono?: boolean; }) { return ( diff --git a/plugin/dashboard/components/common/model-provenance.tsx b/plugin/dashboard/components/common/model-provenance.tsx new file mode 100644 index 0000000..66df39a --- /dev/null +++ b/plugin/dashboard/components/common/model-provenance.tsx @@ -0,0 +1,56 @@ +import { Cpu } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { LearningModelProvenance } from "@/lib/model-lineage"; + +function label(provider: string | null, modelName: string | null): string | null { + if (provider && modelName) return `${provider}/${modelName}`; + return modelName || provider; +} + +export function LearningModelProvenanceView({ + provenance, + className, +}: { + provenance: LearningModelProvenance | null; + className?: string; +}) { + if (!provenance) { + return Loading…; + } + if (provenance.unavailable) { + return ( + + Unavailable + + ); + } + + const observed = label(provenance.provider, provenance.modelName); + return ( + + + + {observed ?? "Not recorded"} + + + ); +} diff --git a/plugin/dashboard/lib/model-lineage.ts b/plugin/dashboard/lib/model-lineage.ts new file mode 100644 index 0000000..6d36176 --- /dev/null +++ b/plugin/dashboard/lib/model-lineage.ts @@ -0,0 +1,172 @@ +/** + * Read observed model/provider from local Reflexio SQLite lineage. + * Direct DB access only — not the Reflexio HTTP API. + */ + +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; + +export type LearningEntityType = + | "profile" + | "user_playbook" + | "agent_playbook"; + +export interface LearningModelProvenance { + entityType: LearningEntityType; + entityId: string; + modelName: string | null; + provider: string | null; + unavailable: boolean; + reason?: string; +} + +type SqliteDatabase = { + prepare: (sql: string) => { + all: (...params: unknown[]) => unknown[]; + get: (...params: unknown[]) => unknown; + }; + close: () => void; +}; + +function empty( + entityType: LearningEntityType, + entityId: string, + unavailable: boolean, + reason?: string, +): LearningModelProvenance { + return { + entityType, + entityId, + modelName: null, + provider: null, + unavailable, + reason, + }; +} + +function dbPath(): string { + const override = process.env.CLAUDE_SMART_REFLEXIO_DB?.trim(); + if (override) return override; + return path.join(os.homedir(), ".reflexio", "data", "reflexio.db"); +} + +function openDatabase(sqlitePath: string): SqliteDatabase { + // Lazy-load so module evaluation stays safe on Node versions that cannot + // import node:sqlite at top level during install/build matrices. + // Use a real filesystem path for createRequire: Next may rewrite + // import.meta.url into a URL object that breaks CommonJS resolution. + const req = createRequire(path.join(process.cwd(), "package.json")); + const sqlite = req("node:sqlite") as { + DatabaseSync: new ( + filename: string, + options?: { readOnly?: boolean }, + ) => SqliteDatabase; + }; + try { + return new sqlite.DatabaseSync(sqlitePath, { readOnly: true }); + } catch { + // Older Node builds may lack the readOnly option; fall back to default open. + return new sqlite.DatabaseSync(sqlitePath); + } +} + +function columns(db: SqliteDatabase, table: string): Set { + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ + name?: string; + }>; + return new Set( + rows.map((row) => row.name).filter((name): name is string => !!name), + ); +} + +function nonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function getLearningModelProvenance( + entityTypeRaw: string, + entityIdRaw: string, + sqlitePath: string = dbPath(), +): LearningModelProvenance { + const entityType = entityTypeRaw as LearningEntityType; + const entityId = String(entityIdRaw ?? "").trim(); + if ( + entityType !== "profile" && + entityType !== "user_playbook" && + entityType !== "agent_playbook" + ) { + return empty("profile", entityId, true, "unsupported entityType"); + } + if (!entityId) { + return empty(entityType, entityId, true, "missing entityId"); + } + if (!fs.existsSync(sqlitePath)) { + console.error(`[model-lineage] database unavailable at ${sqlitePath}`); + return empty(entityType, entityId, true, "database unavailable"); + } + + let db: SqliteDatabase | null = null; + try { + db = openDatabase(sqlitePath); + const cols = columns(db, "lineage_event"); + if (cols.size === 0) { + return empty(entityType, entityId, true, "lineage_event table not present"); + } + + const modelExpr = cols.has("model_name") ? "model_name" : "NULL"; + const providerExpr = cols.has("provider") ? "provider" : "NULL"; + const row = db + .prepare( + `SELECT ${modelExpr} AS model_name, ${providerExpr} AS provider + FROM lineage_event + WHERE entity_type = ? + AND entity_id = ? + ORDER BY + CASE + WHEN COALESCE(${modelExpr}, '') != '' OR COALESCE(${providerExpr}, '') != '' + THEN 0 ELSE 1 + END, + created_at DESC, + event_id DESC + LIMIT 1`, + ) + .get(entityType, entityId) as + | { model_name?: unknown; provider?: unknown } + | undefined; + + if (!row) { + return empty( + entityType, + entityId, + false, + "no lineage events for this learning", + ); + } + + const modelName = nonEmpty(row.model_name); + const provider = nonEmpty(row.provider); + return { + entityType, + entityId, + modelName, + provider, + unavailable: false, + reason: + modelName || provider + ? undefined + : "lineage present but observed model/provider not recorded", + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[model-lineage] provenance query failed: ${message}`); + return empty(entityType, entityId, true, "provenance query failed"); + } finally { + try { + db?.close(); + } catch { + // ignore close failures + } + } +} diff --git a/plugin/dashboard/lib/model-provenance.ts b/plugin/dashboard/lib/model-provenance.ts new file mode 100644 index 0000000..9ba371d --- /dev/null +++ b/plugin/dashboard/lib/model-provenance.ts @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import type { + LearningEntityType, + LearningModelProvenance, +} from "./model-lineage"; + +export type { LearningEntityType, LearningModelProvenance }; + +export function useLearningModelProvenance( + entityType: LearningEntityType | null, + entityId: string | number | null | undefined, +): LearningModelProvenance | null { + const [provenance, setProvenance] = useState( + null, + ); + const ready = + !!entityType && + entityId !== null && + entityId !== undefined && + entityId !== ""; + const requestKey = ready ? `${entityType}:${String(entityId)}` : null; + + useEffect(() => { + if (!requestKey || !entityType) return; + + let cancelled = false; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5_000); + const params = new URLSearchParams({ + entityType, + entityId: String(entityId), + }); + + fetch(`/api/model-provenance?${params.toString()}`, { + cache: "no-store", + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) throw new Error(`model provenance ${response.status}`); + const data = await response.json(); + return data.provenance as LearningModelProvenance; + }) + .then((value) => { + if (!cancelled) setProvenance(value); + }) + .catch(() => { + if (!cancelled) { + setProvenance({ + entityType, + entityId: String(entityId), + modelName: null, + provider: null, + unavailable: true, + reason: "failed to load model provenance", + }); + } + }) + .finally(() => clearTimeout(timeout)); + + return () => { + cancelled = true; + controller.abort(); + clearTimeout(timeout); + }; + }, [requestKey, entityType, entityId]); + + // Drop stale results immediately when the target entity changes so the UI + // shows Loading… instead of the previous learning's model. + if (!requestKey) return null; + if ( + provenance && + `${provenance.entityType}:${provenance.entityId}` !== requestKey + ) { + return null; + } + return provenance; +} diff --git a/plugin/dashboard/types/node-sqlite.d.ts b/plugin/dashboard/types/node-sqlite.d.ts new file mode 100644 index 0000000..3a0120d --- /dev/null +++ b/plugin/dashboard/types/node-sqlite.d.ts @@ -0,0 +1,12 @@ +declare module "node:sqlite" { + export class StatementSync { + all(...params: unknown[]): unknown[]; + get(...params: unknown[]): unknown; + } + + export class DatabaseSync { + constructor(path: string, options?: { readOnly?: boolean }); + prepare(sql: string): StatementSync; + close(): void; + } +} diff --git a/tests/test_model_lineage_reader.py b/tests/test_model_lineage_reader.py new file mode 100644 index 0000000..5a8d26a --- /dev/null +++ b/tests/test_model_lineage_reader.py @@ -0,0 +1,202 @@ +"""Behavioral tests for dashboard model provenance SQLite reader.""" + +from __future__ import annotations + +import json +import os +import shutil +import sqlite3 +import subprocess +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +READER = REPO_ROOT / "plugin" / "dashboard" / "lib" / "model-lineage.ts" +NODE = shutil.which("node") + + +def _require_node() -> str: + if not NODE: + raise AssertionError("node is required to exercise model-lineage.ts") + return NODE + + +def _run_reader(db_path: Path | None, entity_type: str, entity_id: str) -> dict: + node = _require_node() + sqlite_path = ( + str(db_path) + if db_path is not None + else "/tmp/definitely-missing-reflexio-model-lineage.db" + ) + script = f""" +import {{ getLearningModelProvenance }} from {json.dumps(READER.as_uri())}; +const result = getLearningModelProvenance( + {json.dumps(entity_type)}, + {json.dumps(entity_id)}, + {json.dumps(sqlite_path)}, +); +process.stdout.write(JSON.stringify(result)); +""" + proc = subprocess.run( + [node, "--input-type=module", "--eval", script], + check=False, + capture_output=True, + text=True, + cwd=str(REPO_ROOT), + env={**os.environ, "NODE_NO_WARNINGS": "1"}, + ) + assert proc.returncode == 0, proc.stderr or proc.stdout + # node may still emit experimental warnings on stderr; stdout must be pure JSON + return json.loads(proc.stdout) + + +def _create_db(path: Path, *, with_model_cols: bool = True) -> sqlite3.Connection: + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path) + if with_model_cols: + conn.execute( + """ + CREATE TABLE lineage_event ( + event_id INTEGER PRIMARY KEY, + org_id TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + op TEXT NOT NULL, + created_at INTEGER NOT NULL, + model_name TEXT, + provider TEXT + ) + """ + ) + else: + conn.execute( + """ + CREATE TABLE lineage_event ( + event_id INTEGER PRIMARY KEY, + org_id TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + op TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + """ + ) + return conn + + +def test_reader_returns_observed_model_for_profile(tmp_path: Path) -> None: + db = tmp_path / "reflexio.db" + conn = _create_db(db) + conn.execute( + """ + INSERT INTO lineage_event + (event_id, org_id, entity_type, entity_id, op, created_at, model_name, provider) + VALUES + (1, 'org', 'profile', 'pref-1', 'create', 100, 'MiniMax-M3', 'minimax') + """ + ) + conn.commit() + conn.close() + + result = _run_reader(db, "profile", "pref-1") + assert result["unavailable"] is False + assert result["modelName"] == "MiniMax-M3" + assert result["provider"] == "minimax" + assert result["entityType"] == "profile" + assert result["entityId"] == "pref-1" + + +def test_reader_prefers_row_with_observed_model_over_newer_empty_row( + tmp_path: Path, +) -> None: + db = tmp_path / "reflexio.db" + conn = _create_db(db) + conn.executemany( + """ + INSERT INTO lineage_event + (event_id, org_id, entity_type, entity_id, op, created_at, model_name, provider) + VALUES (?, 'org', 'user_playbook', '101', ?, ?, ?, ?) + """, + [ + (1, "create", 100, "MiniMax-M3", "minimax"), + (2, "status_change", 200, None, None), # newer, but no model + ], + ) + conn.commit() + conn.close() + + result = _run_reader(db, "user_playbook", "101") + assert result["unavailable"] is False + assert result["modelName"] == "MiniMax-M3" + assert result["provider"] == "minimax" + + +def test_reader_marks_historical_row_without_model_as_not_recorded( + tmp_path: Path, +) -> None: + db = tmp_path / "reflexio.db" + conn = _create_db(db) + conn.execute( + """ + INSERT INTO lineage_event + (event_id, org_id, entity_type, entity_id, op, created_at, model_name, provider) + VALUES + (1, 'org', 'profile', 'old-pref', 'create', 100, NULL, NULL) + """ + ) + conn.commit() + conn.close() + + result = _run_reader(db, "profile", "old-pref") + assert result["unavailable"] is False + assert result["modelName"] is None + assert result["provider"] is None + assert "not recorded" in (result.get("reason") or "").lower() + + +def test_reader_supports_schema_without_model_columns(tmp_path: Path) -> None: + db = tmp_path / "legacy.db" + conn = _create_db(db, with_model_cols=False) + conn.execute( + """ + INSERT INTO lineage_event + (event_id, org_id, entity_type, entity_id, op, created_at) + VALUES + (1, 'org', 'agent_playbook', '202', 'create', 100) + """ + ) + conn.commit() + conn.close() + + result = _run_reader(db, "agent_playbook", "202") + assert result["unavailable"] is False + assert result["modelName"] is None + assert result["provider"] is None + + +def test_reader_missing_entity_is_not_unavailable(tmp_path: Path) -> None: + db = tmp_path / "reflexio.db" + conn = _create_db(db) + conn.commit() + conn.close() + + result = _run_reader(db, "profile", "missing-id") + assert result["unavailable"] is False + assert result["modelName"] is None + assert result["provider"] is None + assert "no lineage events" in (result.get("reason") or "").lower() + + +def test_reader_missing_database_is_unavailable() -> None: + result = _run_reader(None, "profile", "pref-1") + assert result["unavailable"] is True + assert result["reason"] == "database unavailable" + + +def test_reader_missing_lineage_table_is_unavailable(tmp_path: Path) -> None: + db = tmp_path / "reflexio.db" + sqlite3.connect(db).close() + + result = _run_reader(db, "profile", "pref-1") + assert result["unavailable"] is True + assert result["reason"] == "lineage_event table not present"