diff --git a/dashboards/open-brain-dashboard-next/components/AddToBrain.tsx b/dashboards/open-brain-dashboard-next/components/AddToBrain.tsx index 998a50628..9f57e6187 100644 --- a/dashboards/open-brain-dashboard-next/components/AddToBrain.tsx +++ b/dashboards/open-brain-dashboard-next/components/AddToBrain.tsx @@ -71,7 +71,7 @@ export function AddToBrain({ const [executing, setExecuting] = useState(false); const [executeError, setExecuteError] = useState(null); - const fetchJobDetail = useCallback(async (jobId: number) => { + const fetchJobDetail = useCallback(async (jobId: string) => { setLoadingDetail(true); try { const res = await fetch(`/api/ingest/${jobId}`); diff --git a/dashboards/open-brain-dashboard-next/lib/api.ts b/dashboards/open-brain-dashboard-next/lib/api.ts index 3bc7c1dc7..9b5b7f5d4 100644 --- a/dashboards/open-brain-dashboard-next/lib/api.ts +++ b/dashboards/open-brain-dashboard-next/lib/api.ts @@ -229,7 +229,7 @@ export async function triggerIngest( apiKey: string, text: string, opts?: { dry_run?: boolean } -): Promise<{ job_id: number; status: string }> { +): Promise<{ job_id: string; status: string }> { return apiFetch(apiKey, "/ingest", { method: "POST", body: JSON.stringify({ text, ...opts }), diff --git a/dashboards/open-brain-dashboard-next/lib/types.ts b/dashboards/open-brain-dashboard-next/lib/types.ts index b5b9fb63f..92a39880e 100644 --- a/dashboards/open-brain-dashboard-next/lib/types.ts +++ b/dashboards/open-brain-dashboard-next/lib/types.ts @@ -77,7 +77,7 @@ export interface Reflection { } export interface IngestionJob { - id: number; + id: string; source_label: string; status: string; extracted_count: number; @@ -142,8 +142,8 @@ export interface ReflectionInput { } export interface IngestionItem { - id: number | string; - job_id: number; + id: string; + job_id: string; content: string; type: string; fingerprint: string; @@ -164,7 +164,7 @@ export type AddToBrainMode = "auto" | "single" | "extract"; export interface AddToBrainResult { path: "single" | "extract"; thought_id?: string; - job_id?: number; + job_id?: string; type?: string; status?: string; extracted_count?: number | null; diff --git a/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/execute/route.ts b/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/execute/route.ts index dda2ca3ea..d5a8af185 100644 --- a/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/execute/route.ts +++ b/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/execute/route.ts @@ -16,9 +16,11 @@ export async function POST( const { id } = await params; - // WR-04 / BL-03: Validate id is a positive integer before forwarding - const idNum = Number(id); - if (!Number.isInteger(idNum) || idNum <= 0) { + // WR-04 / BL-03: Validate id is a UUID before forwarding. OB1 ingestion job + // ids are UUIDs, not integers — the old positive-integer check rejected them. + const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!UUID_RE.test(id)) { return NextResponse.json({ error: "Invalid id" }, { status: 400 }); } @@ -34,7 +36,7 @@ export async function POST( // BL-03: Re-verify the session owns this job by fetching it first. // The REST gateway filters by the session's x-brain-key, so a 404/403 here // indicates the job is not visible to the caller. - const verifyRes = await fetch(`${API_URL}/ingestion-jobs/${idNum}`, { + const verifyRes = await fetch(`${API_URL}/ingestion-jobs/${id}`, { headers: { "x-brain-key": apiKey, "Content-Type": "application/json" }, }); if (!verifyRes.ok) { @@ -43,7 +45,7 @@ export async function POST( // of a misleading "denied" message. if (verifyRes.status >= 500) { console.error( - `[ingest/[id]/execute] preflight upstream 5xx for job ${idNum}:`, + `[ingest/[id]/execute] preflight upstream 5xx for job ${id}:`, verifyRes.status ); return NextResponse.json( @@ -62,7 +64,7 @@ export async function POST( ); } - const res = await fetch(`${API_URL}/ingestion-jobs/${idNum}/execute`, { + const res = await fetch(`${API_URL}/ingestion-jobs/${id}/execute`, { method: "POST", headers: { "x-brain-key": apiKey, "Content-Type": "application/json" }, }); diff --git a/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/route.ts b/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/route.ts index 8a89998bf..bf52458bc 100644 --- a/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/route.ts +++ b/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/route.ts @@ -13,15 +13,15 @@ function normalizeItem(raw: Record): IngestionItem { }; return { - id: raw.id as number, - job_id: raw.job_id as number, + id: raw.id as string, + job_id: raw.job_id as string, content: (raw.extracted_content ?? raw.content ?? "") as string, action: (raw.action ?? "skip") as string, reason: (raw.reason as string) ?? null, status: (raw.status ?? "pending") as string, - matched_thought_id: (raw.matched_thought_id as number) ?? null, + matched_thought_id: (raw.matched_thought_id as string) ?? null, similarity_score: raw.similarity_score != null ? Number(raw.similarity_score) : null, - result_thought_id: (raw.result_thought_id as number) ?? null, + result_thought_id: (raw.result_thought_id as string) ?? null, meta: parsedMeta, }; } @@ -41,9 +41,11 @@ export async function GET( const { id } = await params; - // WR-04: Validate id is a positive integer before forwarding - const idNum = Number(id); - if (!Number.isInteger(idNum) || idNum <= 0) { + // WR-04: Validate id is a UUID before forwarding. OB1 ingestion job ids are + // UUIDs, not integers — the old positive-integer check rejected them. + const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!UUID_RE.test(id)) { return NextResponse.json({ error: "Invalid id" }, { status: 400 }); } @@ -56,7 +58,7 @@ export async function GET( } try { - const res = await fetch(`${API_URL}/ingestion-jobs/${idNum}`, { + const res = await fetch(`${API_URL}/ingestion-jobs/${id}`, { headers: { "x-brain-key": apiKey, "Content-Type": "application/json" }, }); const data = await res.json(); diff --git a/dashboards/open-brain-dashboard-pro/components/AddToBrain.tsx b/dashboards/open-brain-dashboard-pro/components/AddToBrain.tsx index ddfcf4a02..9882f6f16 100644 --- a/dashboards/open-brain-dashboard-pro/components/AddToBrain.tsx +++ b/dashboards/open-brain-dashboard-pro/components/AddToBrain.tsx @@ -182,7 +182,7 @@ export function AddToBrain({ const [executing, setExecuting] = useState(false); const [executeError, setExecuteError] = useState(null); - const fetchJobDetail = useCallback(async (jobId: number) => { + const fetchJobDetail = useCallback(async (jobId: string) => { setLoadingDetail(true); try { const res = await fetch(`/api/ingest/${jobId}`); diff --git a/dashboards/open-brain-dashboard-pro/lib/api.ts b/dashboards/open-brain-dashboard-pro/lib/api.ts index 4afbfaf94..e35dfcdcd 100644 --- a/dashboards/open-brain-dashboard-pro/lib/api.ts +++ b/dashboards/open-brain-dashboard-pro/lib/api.ts @@ -247,7 +247,7 @@ export async function triggerIngest( apiKey: string, text: string, opts?: { dry_run?: boolean; skip_classification?: boolean } -): Promise<{ job_id: number; status: string }> { +): Promise<{ job_id: string; status: string }> { return apiFetch(apiKey, "/ingest", { method: "POST", body: JSON.stringify({ text, ...opts }), diff --git a/dashboards/open-brain-dashboard-pro/lib/types.ts b/dashboards/open-brain-dashboard-pro/lib/types.ts index 5d05bb130..d792b7bc5 100644 --- a/dashboards/open-brain-dashboard-pro/lib/types.ts +++ b/dashboards/open-brain-dashboard-pro/lib/types.ts @@ -13,7 +13,7 @@ export interface Thought { } export interface IngestionJob { - id: number; + id: string; source_label: string; status: string; extracted_count: number; @@ -69,16 +69,16 @@ export interface IngestionItemMeta { } export interface IngestionItem { - id: number; - job_id: number; + id: string; + job_id: string; /** The extracted thought content (DB column: extracted_content) */ content: string; action: string; // add, skip, create_revision, append_evidence reason: string | null; status: string; - matched_thought_id: number | null; + matched_thought_id: string | null; similarity_score: number | null; - result_thought_id: number | null; + result_thought_id: string | null; /** Parsed metadata — type, importance, tags, source_snippet */ meta: IngestionItemMeta; } @@ -93,7 +93,7 @@ export type AddToBrainMode = "auto" | "single" | "extract"; export interface AddToBrainResult { path: "single" | "extract"; thought_id?: number; - job_id?: number; + job_id?: string; type?: string; status?: string; extracted_count?: number | null; diff --git a/integrations/smart-ingest/README.md b/integrations/smart-ingest/README.md index cea6c8f62..1522794ca 100644 --- a/integrations/smart-ingest/README.md +++ b/integrations/smart-ingest/README.md @@ -173,7 +173,7 @@ You should get a response showing extracted thoughts and their reconciliation ac ```json { "status": "dry_run_complete", - "job_id": 1, + "job_id": "123e4567-e89b-12d3-a456-426614174000", "extracted_count": 3, "added_count": 3, "skipped_count": 0, @@ -189,7 +189,7 @@ Once you're satisfied with the dry-run results, commit them to the database: curl -X POST "https://.supabase.co/functions/v1/smart-ingest/execute" \ -H "Content-Type: application/json" \ -H "x-brain-key: your-access-key" \ - -d '{ "job_id": 1 }' + -d '{ "job_id": "123e4567-e89b-12d3-a456-426614174000" }' ``` ### 5. Verify Results @@ -237,7 +237,7 @@ Execute a previously dry-run job. | Parameter | Type | Description | |-----------|------|-------------| -| `job_id` | number | ID of the dry-run job to execute | +| `job_id` | string (UUID) | ID of the dry-run job to execute | | `skip_classification` | boolean | Override classification behavior for this execution | ## How It Connects to Other Components diff --git a/integrations/smart-ingest/index.ts b/integrations/smart-ingest/index.ts index b19c7560f..cac3ac940 100644 --- a/integrations/smart-ingest/index.ts +++ b/integrations/smart-ingest/index.ts @@ -141,14 +141,14 @@ interface IngestionItem { content_fingerprint: string; action: ReconcileAction; reason: string; - matched_thought_id: number | null; + matched_thought_id: string | null; similarity_score: number | null; status: "pending" | "executed" | "failed"; error_message: string | null; } interface IngestionJob { - id?: number; + id?: string; input_hash: string; source_label: string | null; source_type: string | null; @@ -164,8 +164,8 @@ interface IngestionJob { } type UpsertThoughtResult = { - thought_id?: number; - id?: number; + thought_id?: string; + id?: string; }; // ── Auth ──────────────────────────────────────────────────────────────────── @@ -347,15 +347,17 @@ function mergeTags(existing: unknown, extras: string[]): string[] { ]); } -function extractThoughtId(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) return value; +function extractThoughtId(value: unknown): string | null { + // OB1 thought ids are UUIDs (strings). A successful upsert_thought may return + // the id as a bare string, or wrapped as { thought_id } / { id }. + if (typeof value === "string" && value.trim().length > 0) return value; if (value && typeof value === "object" && "thought_id" in value) { const thoughtId = (value as UpsertThoughtResult).thought_id; - if (typeof thoughtId === "number" && Number.isFinite(thoughtId)) return thoughtId; + if (typeof thoughtId === "string" && thoughtId.trim().length > 0) return thoughtId; } if (value && typeof value === "object" && "id" in value) { const id = (value as UpsertThoughtResult).id; - if (typeof id === "number" && Number.isFinite(id)) return id; + if (typeof id === "string" && id.trim().length > 0) return id; } return null; } @@ -595,7 +597,7 @@ async function reconcileThought( tags: thought.tags, source_snippet: thought.source_snippet, content_fingerprint: fingerprint, - matched_thought_id: null as number | null, + matched_thought_id: null as string | null, similarity_score: null as number | null, }; @@ -649,7 +651,7 @@ async function reconcileThought( const topMatch = matches[0]; const similarity = topMatch.similarity as number; - const matchedId = topMatch.id as number; + const matchedId = topMatch.id as string; const existingContent = (topMatch.content ?? "") as string; base.matched_thought_id = matchedId; @@ -679,7 +681,7 @@ async function executeItem( sourceType: string | null, sourceMetadata?: Record | null, skipClassification = false, -): Promise { +): Promise { switch (item.action) { case "add": { const prepared = await prepareThoughtPayload(item.content, { @@ -822,7 +824,7 @@ async function createJob( job: IngestionJob, sourceMetadata?: Record | null, inputLength: number = 0, -): Promise { +): Promise { const { data, error } = await supabase.from("ingestion_jobs").insert({ input_hash: job.input_hash, source_label: job.source_label, @@ -833,13 +835,13 @@ async function createJob( }).select("id").single(); if (error) { console.error("Failed to create ingestion_jobs row:", error.message); - return 0; + return ""; } - return data?.id ?? 0; + return data?.id ?? ""; } async function updateJobById( - jobId: number, + jobId: string, updates: Record, ): Promise<{ ok: boolean; error?: string }> { const { data, error } = await supabase @@ -849,21 +851,21 @@ async function updateJobById( .select("id, status") .maybeSingle(); if (error) { - console.error(`Failed to update job #${jobId}: ${error.message} (code: ${error.code}, details: ${error.details})`); + console.error(`Failed to update job ${jobId}: ${error.message} (code: ${error.code}, details: ${error.details})`); return { ok: false, error: `${error.code}: ${error.message}` }; } if (!data) { - console.error(`updateJobById: update matched 0 rows for job #${jobId}`); - return { ok: false, error: `No row matched for job #${jobId}` }; + console.error(`updateJobById: update matched 0 rows for job ${jobId}`); + return { ok: false, error: `No row matched for job ${jobId}` }; } return { ok: true }; } async function persistItems( - jobId: number, + jobId: string, items: IngestionItem[], sourceMetadata?: Record | null, -): Promise { +): Promise { if (items.length === 0 || !jobId) return []; const rows = items.map((item) => ({ job_id: jobId, @@ -887,21 +889,26 @@ async function persistItems( console.error("Failed to persist ingestion_items:", error.message); return []; } - return (data ?? []).map((row: { id: number }) => row.id); + return (data ?? []).map((row: { id: string }) => row.id); } // ── Execute a dry-run job ─────────────────────────────────────────────────── +// OB1 ingestion_jobs.id is a UUID; reject anything that is not a UUID string. +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + async function handleExecuteJob(req: Request): Promise { let body: Record; try { body = await req.json(); } catch { return json({ error: "Invalid JSON body" }, 400); } - const jobId = typeof body.job_id === "number" ? body.job_id : 0; - if (!jobId) return json({ error: "job_id is required" }, 400); + const jobId = typeof body.job_id === "string" && UUID_RE.test(body.job_id.trim()) + ? body.job_id.trim() + : ""; + if (!jobId) return json({ error: "job_id is required (must be a UUID)" }, 400); const { data: job, error: jobErr } = await supabase .from("ingestion_jobs").select("*").eq("id", jobId).single(); - if (jobErr || !job) return json({ error: `Job #${jobId} not found` }, 404); + if (jobErr || !job) return json({ error: `Job ${jobId} not found` }, 404); if (job.status === "complete") return json({ ...job, message: "Job already complete" }, 200); if (job.status !== "dry_run_complete") { return json({ error: `Job status is '${job.status}', expected 'dry_run_complete'` }, 400); @@ -1191,7 +1198,7 @@ Deno.serve(async (req) => { } // Persist items to ingestion_items table - let itemIds: number[] = []; + let itemIds: string[] = []; if (jobId) itemIds = await persistItems(jobId, items, sourceMetadata); if (dryRun) { @@ -1234,7 +1241,7 @@ Deno.serve(async (req) => { } for (let i = 0; i < items.length; i++) { const item = items[i]; - const itemDbId = itemIds[i] ?? 0; + const itemDbId = itemIds[i] ?? ""; if (item.action === "skip") { item.status = "executed"; if (itemDbId) await supabase.from("ingestion_items").update({ status: "executed" }).eq("id", itemDbId);