From 71ba596c0733f3f1af94aef3c1485755b287386a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 20:48:30 -0700 Subject: [PATCH 1/2] fix: thread triples reference the thread's row id, not its "t-" id (GIT-105) knowledge_triples.source_id is a UUID column on every schema, including the v1.8.0 setup.sql. Thread triples wrote the thread id ("t-xxxxxxxx") into it, so every create_thread / resolve_thread triple failed with 400 (22P02) and the failure was swallowed. No schema change needed: - source_id is the thread's gitmem_threads.id, a UUID the store already has. create_thread passes the row id createThreadInSupabase returned; resolution triples look it up by thread_id. A failed or empty lookup writes the triple with source_id null rather than losing it. - writeTriples never sends a non-UUID source_id from any caller; it logs and sends null instead. thread-triples.test.ts: two assertions expected source_id "t-abc12345", the value the column rejects. They now expect the row id. +5 tests. Co-Authored-By: Claude Opus 5 --- src/services/triple-writer.ts | 57 +++++++++++++--- src/tools/create-thread.ts | 3 + .../services/thread-triples-write.test.ts | 68 +++++++++++++++++++ tests/unit/services/thread-triples.test.ts | 21 +++++- 4 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 tests/unit/services/thread-triples-write.test.ts diff --git a/src/services/triple-writer.ts b/src/services/triple-writer.ts index 81d8410..009560c 100644 --- a/src/services/triple-writer.ts +++ b/src/services/triple-writer.ts @@ -12,7 +12,7 @@ import { v4 as uuidv4 } from "uuid"; import * as supabase from "./supabase-client.js"; -import { hasSupabase } from "./tier.js"; +import { hasSupabase, getTableName } from "./tier.js"; // --- Types --- @@ -32,7 +32,8 @@ interface TripleCandidate { predicate: Predicate; object: string; source_type: string; - source_id: string; + /** knowledge_triples.source_id is a UUID column: a row id, or null. */ + source_id: string | null; source_linear_issue?: string; domain?: string[]; project: string; @@ -282,11 +283,24 @@ export function extractDecisionTriples(params: DecisionTripleParams): TripleCand * failure (GIT-104) — callers run this under getEffectTracker().track(), which * never rethrows. */ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + export async function writeTriples(candidates: TripleCandidate[]): Promise { if (candidates.length === 0 || !hasSupabase()) { return 0; } + // GIT-105: source_id is a UUID column in every schema. A non-UUID value + // (thread ids are "t-xxxxxxxx") failed the whole row with 22P02, so every + // thread triple 400'd and was lost. Never send one; the subject/object + // labels still carry the relation. + for (const c of candidates) { + if (c.source_id !== null && !UUID_RE.test(c.source_id)) { + console.error(`[triple-writer] source_id "${c.source_id}" is not a UUID — writing the triple with source_id null`); + c.source_id = null; + } + } + let written = 0; const errors: string[] = []; @@ -356,6 +370,12 @@ export function writeTriplesForDecision(params: DecisionTripleParams): Promise { - const triples = extractThreadCreationTriples(params); +export async function writeTriplesForThreadCreation(params: ThreadCreationTripleParams): Promise { + const triples = extractThreadCreationTriples(await withThreadRowId(params)); return writeTriples(triples); } +/** + * GIT-105: fill thread_row_id from the store when the caller did not have it. + * A failed or empty lookup leaves it null — the triple is still written. + */ +async function withThreadRowId(params: T): Promise { + if (params.thread_row_id !== undefined || !hasSupabase()) return params; + try { + const rows = await supabase.directQuery<{ id: string }>(getTableName("threads"), { + select: "id", + filters: { thread_id: params.thread_id }, + limit: 1, + }); + return { ...params, thread_row_id: rows[0]?.id ?? null }; + } catch (error) { + console.error(`[triple-writer] Could not look up the row id of ${params.thread_id}:`, error instanceof Error ? error.message : error); + return { ...params, thread_row_id: null }; + } +} + /** * Generate and write triples for a resolved thread. * Fire-and-forget — run under getEffectTracker().track(); rejects if any triple failed. */ -export function writeTriplesForThreadResolution(params: ThreadResolutionTripleParams): Promise { - const triples = extractThreadResolutionTriples(params); +export async function writeTriplesForThreadResolution(params: ThreadResolutionTripleParams): Promise { + const triples = extractThreadResolutionTriples(await withThreadRowId(params)); return writeTriples(triples); } diff --git a/src/tools/create-thread.ts b/src/tools/create-thread.ts index 691de05..7a74b64 100644 --- a/src/tools/create-thread.ts +++ b/src/tools/create-thread.ts @@ -296,12 +296,14 @@ export async function createThread( // Write to Supabase (source of truth) with embedding. let supabaseSynced = false; let supabaseError: string | undefined; + let threadRowId: string | null = null; // GIT-105: gitmem_threads.id, for triple source_id const embeddingJson = newEmbedding ? JSON.stringify(newEmbedding) : null; if (hasSupabase()) { try { const supabaseResult = await createThreadInSupabase(thread, project, embeddingJson); supabaseSynced = Boolean(supabaseResult); + threadRowId = supabaseResult?.id ?? null; if (!supabaseSynced) { supabaseError = "write returned no row"; } @@ -347,6 +349,7 @@ export async function createThread( getEffectTracker().track("triple_write", "thread_creation", () => writeTriplesForThreadCreation({ thread_id: thread.id, + thread_row_id: threadRowId, text: trimmedText, linear_issue: params.linear_issue, session_id: sessionId, diff --git a/tests/unit/services/thread-triples-write.test.ts b/tests/unit/services/thread-triples-write.test.ts new file mode 100644 index 0000000..cb7f84a --- /dev/null +++ b/tests/unit/services/thread-triples-write.test.ts @@ -0,0 +1,68 @@ +/** + * GIT-105: thread triples reach knowledge_triples on a v1.8.0 store. + * + * source_id is a UUID column; thread ids are "t-xxxxxxxx". Every + * create_thread / resolve_thread triple 400'd (22P02) and the failure was + * swallowed. The writer now stores the thread's row id (gitmem_threads.id), + * looking it up when the caller does not have it, and never sends a non-UUID. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const store = vi.hoisted(() => ({ + upserts: [] as Array>, + queries: [] as Array<{ table: string; filters: Record }>, + threadRows: {} as Record, + queryFails: false, +})); + +vi.mock("../../../src/services/supabase-client.js", () => ({ + directUpsert: vi.fn(async (_t: string, row: Record) => { store.upserts.push(row); return row; }), + directQuery: vi.fn(async (table: string, opts: { filters: Record }) => { + store.queries.push({ table, filters: opts.filters }); + if (store.queryFails) throw new Error("store down"); + const id = store.threadRows[opts.filters.thread_id]; + return id ? [{ id }] : []; + }), +})); +vi.mock("../../../src/services/tier.js", async (orig) => ({ + ...(await orig()), + hasSupabase: () => true, +})); + +import { writeTriplesForThreadCreation, writeTriplesForThreadResolution, writeTriples } from "../../../src/services/triple-writer.js"; + +const ROW = "7d1c9a52-3f4e-4b8a-9c21-5e6f7a8b9c0d"; +const SESSION = "550e8400-e29b-41d4-a716-446655440000"; +const base = { thread_id: "t-abc12345", text: "Fix auth timeout", session_id: SESSION, project: "p", agent: "cli" }; + +beforeEach(() => { + store.upserts = []; store.queries = []; store.threadRows = {}; store.queryFails = false; +}); + +describe("thread triples on a UUID source_id column (GIT-105)", () => { + it("creation: uses the row id create_thread already has — no lookup", async () => { + await writeTriplesForThreadCreation({ ...base, thread_row_id: ROW }); + expect(store.queries).toHaveLength(0); + expect(store.upserts.map((r) => r.source_id)).toEqual([ROW]); + }); + + it("resolution: looks the row id up by thread_id", async () => { + store.threadRows["t-abc12345"] = ROW; + await writeTriplesForThreadResolution(base); + expect(store.queries).toEqual([{ table: "gitmem_threads", filters: { thread_id: "t-abc12345" } }]); + expect(store.upserts.map((r) => r.source_id)).toEqual([ROW]); + }); + + it("thread not in the store, or the lookup fails: source_id null, triple still written", async () => { + await writeTriplesForThreadResolution(base); + store.queryFails = true; + await writeTriplesForThreadResolution(base); + expect(store.upserts.map((r) => r.source_id)).toEqual([null, null]); + }); + + it("never sends a non-UUID source_id, whatever the caller passed", async () => { + await writeTriples([{ subject: "s", predicate: "created_thread", object: "o", source_type: "thread", + source_id: "t-abc12345", project: "p", half_life_days: 9999, created_by: "cli" }]); + expect(store.upserts[0].source_id).toBeNull(); + }); +}); diff --git a/tests/unit/services/thread-triples.test.ts b/tests/unit/services/thread-triples.test.ts index 8de0a03..fd2853c 100644 --- a/tests/unit/services/thread-triples.test.ts +++ b/tests/unit/services/thread-triples.test.ts @@ -17,8 +17,13 @@ import type { // ---------- Helpers ---------- +// GIT-105: source_id is the thread's row id (gitmem_threads.id, a UUID), not +// its "t-" thread_id — knowledge_triples.source_id is a UUID column. +const THREAD_ROW_ID = "7d1c9a52-3f4e-4b8a-9c21-5e6f7a8b9c0d"; + const BASE_CREATION_PARAMS: ThreadCreationTripleParams = { thread_id: "t-abc12345", + thread_row_id: THREAD_ROW_ID, text: "Fix auth timeout in production", session_id: "550e8400-e29b-41d4-a716-446655440000", linear_issue: "PROJ-123", @@ -28,6 +33,7 @@ const BASE_CREATION_PARAMS: ThreadCreationTripleParams = { const BASE_RESOLUTION_PARAMS: ThreadResolutionTripleParams = { thread_id: "t-abc12345", + thread_row_id: THREAD_ROW_ID, text: "Fix auth timeout in production", resolution_note: "Fixed by increasing timeout to 30s", session_id: "660e8400-e29b-41d4-a716-446655440000", @@ -88,7 +94,8 @@ describe("extractThreadCreationTriples", () => { for (const triple of triples) { expect(triple.source_type).toBe("thread"); - expect(triple.source_id).toBe("t-abc12345"); + // GIT-105: was "t-abc12345", which the UUID column rejects (22P02). + expect(triple.source_id).toBe(THREAD_ROW_ID); expect(triple.half_life_days).toBe(9999); expect(triple.project).toBe("test-project"); expect(triple.created_by).toBe("CLI"); @@ -129,7 +136,17 @@ describe("extractThreadResolutionTriples", () => { expect(triple.subject).toMatch(/^Session: /); expect(triple.object).toMatch(/^Thread: /); expect(triple.source_type).toBe("thread"); - expect(triple.source_id).toBe("t-abc12345"); + // GIT-105: was "t-abc12345", which the UUID column rejects (22P02). + expect(triple.source_id).toBe(THREAD_ROW_ID); + }); + + it("without a row id, source_id is null — never the thread_id (GIT-105)", () => { + for (const t of [ + ...extractThreadCreationTriples({ ...BASE_CREATION_PARAMS, thread_row_id: null }), + ...extractThreadResolutionTriples({ ...BASE_RESOLUTION_PARAMS, thread_row_id: undefined }), + ]) { + expect(t.source_id).toBeNull(); + } }); }); From b4abf5e72425dacae003723699125b1b4cd20447 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 20:48:30 -0700 Subject: [PATCH 2/2] test: venue flow requires linked thread triples; remove GIT-105 from expected failures (GIT-105) Exits 1 on 1.10.0 (0 thread triples stored); exits 0 with the fix. Co-Authored-By: Claude Opus 5 --- tests/e2e/blank-supabase.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/e2e/blank-supabase.mjs b/tests/e2e/blank-supabase.mjs index 84b343b..3c52dc7 100644 --- a/tests/e2e/blank-supabase.mjs +++ b/tests/e2e/blank-supabase.mjs @@ -73,7 +73,6 @@ function isCapabilityProbe(r) { const EXPECTED_FAILURES = [ { ticket: "GIT-73", what: "metrics/session_start FK race", method: "POST", path: /^\/rest\/v1\/gitmem_query_metrics$/, status: 409 }, - { ticket: "GIT-105", what: "knowledge-triple thread id into a uuid column", method: "POST", path: /^\/rest\/v1\/knowledge_triples$/, status: 400 }, ]; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = resolve(HERE, "../.."); @@ -377,6 +376,11 @@ async function flow() { await waitQuiet(netlog, { minMs: 2000, quietMs: 3000 }); const health = await step("health", { failure_limit: 20 }); + // GIT-105: thread triples land, and their source_id is a real thread row id. + const threadTriples = await rest("GET", "knowledge_triples?select=predicate,source_id&source_type=eq.thread", undefined, { Prefer: "" }).then((r) => r.json()); + const threadRowIds = new Set((await rest("GET", "gitmem_threads?select=id", undefined, { Prefer: "" }).then((r) => r.json())).map((t) => t.id)); + const threadTriplesLinked = threadTriples.length > 0 && threadTriples.every((t) => threadRowIds.has(t.source_id)); + // Did the close land? Is relevance readable from the store (GIT-109)? const closedRows = sessionId ? await rest("GET", `gitmem_sessions?select=id,closing_reflection&id=eq.${sessionId}`, undefined, { Prefer: "" }).then((r) => r.json()) @@ -408,6 +412,8 @@ async function flow() { remote_sessions_after: await count("gitmem_sessions"), remote_scar_usage_after: await count("gitmem_scar_usage"), session_close_persisted: closedRows.length === 1 && closedRows[0].closing_reflection != null, + thread_triples: threadTriples.length, + thread_triples_linked: threadTriplesLinked, relevance_readable: relevance.some((r) => scarIds.some((id) => (r.memories_applied || []).includes(id) && r.memory_relevance?.[id])), relevance, health_failed_total: failed, @@ -520,6 +526,9 @@ const failureCheck = checkFailures(allVenueRequests); const edgeCalls = allVenueRequests.filter((r) => r.path.startsWith("/functions/v1/")) .map((r) => `${r.method} ${r.path} -> ${r.status}`); if (edgeCalls.length) failureCheck.unexpected.push(...edgeCalls.map((c) => `edge function called (GIT-97): ${c}`)); +if (mode === "flow" && !result.thread_triples_linked) { + failureCheck.unexpected.push(`thread triples missing or not linked to a gitmem_threads row (GIT-105): ${result.thread_triples} found`); +} if (mode === "flow" && !result.session_close_persisted) { failureCheck.unexpected.push(`session_close did not persist session ${result.session_id} (closing_reflection missing)`); }