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
5 changes: 5 additions & 0 deletions src/services/local-vector-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,11 @@ export class LocalVectorSearch {
return this.scars.length;
}

/** Ids of the indexed scars (GIT-119: prefix resolution without a query). */
getScarIds(): string[] {
return this.scars.map((s) => s.scar.id);
}

/**
* Search for scars similar to the query
*
Expand Down
4 changes: 3 additions & 1 deletion src/services/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,9 +443,11 @@ export async function updateRelevanceData(
);
if (applied.length === 0 && Object.keys(relevance).length === 0) continue;

await supabase.directPatch("gitmem_query_metrics", { id: `eq.${metric.id}` }, {
const patched = await supabase.directPatch("gitmem_query_metrics", { id: `eq.${metric.id}` }, {
metadata: { ...(metric.metadata ?? {}), memories_applied: applied, memory_relevance: relevance },
});
// GIT-119: 0 rows = relevance not recorded; throw so the tracker counts it.
if (patched.count === 0) throw new Error(`relevance for metrics row ${metric.id} updated no row`);
}
});
}
Expand Down
20 changes: 17 additions & 3 deletions src/services/supabase-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,11 +473,22 @@ export async function directUpsert<T = unknown>(
* @param filters PostgREST filter to identify the row(s) to update
* @param data Fields to update (partial — omitted columns stay unchanged)
*/
/**
* GIT-119: what a PATCH changed. PostgREST answers 200 with [] when the filter
* matched nothing (a wrong id, a row not created yet, RLS), so a PATCH that
* returns is not evidence of a write: `count` is. All callers treat 0 as not
* durable.
*/
export interface PatchResult<T = unknown> {
count: number;
rows: T[];
}

export async function directPatch<T = unknown>(
table: string,
filters: Record<string, string>,
data: Record<string, unknown>
): Promise<T[]> {
): Promise<PatchResult<T>> {
if (!isConfigured()) {
throw new Error("Supabase not configured");
}
Expand Down Expand Up @@ -507,7 +518,8 @@ export async function directPatch<T = unknown>(
throw new Error(`Supabase patch error: ${response.status} - ${text.slice(0, 200)}`);
}

return response.json() as Promise<T[]>;
const rows = ((await response.json()) as T[]) || [];
return { count: Array.isArray(rows) ? rows.length : 0, rows: Array.isArray(rows) ? rows : [] };
}

/**
Expand Down Expand Up @@ -888,10 +900,12 @@ export async function saveTranscript(
// Update the session record with transcript_path (direct REST API)
let patch_warning: string | undefined;
try {
await directPatch(getTableName("sessions"),
const patched = await directPatch(getTableName("sessions"),
{ id: sessionId },
{ transcript_path: path }
);
// GIT-119: 0 rows = no session row to update, not a success.
if (patched.count === 0) throw new Error(`no session row ${sessionId} to update`);
} catch (error) {
// File is saved; session record update failed — warn, don't fail
const msg = error instanceof Error ? error.message : String(error);
Expand Down
17 changes: 13 additions & 4 deletions src/services/thread-supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,12 @@ export async function resolveThreadInSupabase(
patchData.resolved_by_session = options.resolvedBySession;
}

await supabase.directPatch(getTableName("threads"), { id: uuid }, patchData);
const patched = await supabase.directPatch(getTableName("threads"), { id: uuid }, patchData);
// GIT-119: 0 rows = not resolved.
if (patched.count === 0) {
console.error(`[thread-supabase] Resolve of ${threadId} updated no row`);
return false;
}
console.error(`[thread-supabase] Resolved thread ${threadId} in Supabase`);
return true;
} catch (error) {
Expand Down Expand Up @@ -551,13 +556,15 @@ export async function touchThreadsInSupabase(
delete metadata.dormant_since;
}

await supabase.directPatch(getTableName("threads"), { id: row.id }, {
const patched = await supabase.directPatch(getTableName("threads"), { id: row.id }, {
touch_count: newTouchCount,
last_touched_at: nowIso,
vitality_score: vitality.vitality_score,
status: lifecycle_status,
metadata,
});
// GIT-119: 0 rows = not touched.
if (patched.count === 0) throw new Error(`touch of ${threadId} updated no row`);
} catch (error) {
console.error(`[thread-supabase] Failed to touch thread ${threadId}:`, error instanceof Error ? error.message : error);
// Continue with other threads
Expand Down Expand Up @@ -752,10 +759,12 @@ export async function archiveDormantThreads(
const daysDormant = (now.getTime() - dormantStart.getTime()) / (1000 * 60 * 60 * 24);

if (daysDormant >= dormantDays) {
await supabase.directPatch(getTableName("threads"), { id: row.id }, {
const patched = await supabase.directPatch(getTableName("threads"), { id: row.id }, {
status: "archived",
});
archived_ids.push(row.thread_id);
// GIT-119: count only rows the store actually archived.
if (patched.count > 0) archived_ids.push(row.thread_id);
else console.error(`[thread-supabase] Archive of ${row.thread_id} updated no row`);
}
}

Expand Down
49 changes: 46 additions & 3 deletions src/tools/archive-learning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { supportedColumns } from "../services/store-columns.js";
import { hasSupabase, getTableName } from "../services/tier.js";
import { getStorage } from "../services/storage.js";
import { flushCache } from "../services/startup.js";
import { getLocalVectorSearch } from "../services/local-vector-search.js";
import { Timer } from "../services/metrics.js";
import { wrapDisplay } from "../services/display-protocol.js";
import { writeResult, notStored } from "../services/write-result.js";
Expand All @@ -37,6 +38,25 @@ export interface ArchiveLearningResult extends WriteResult {
}

const FULL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/**
* GIT-119: the UUIDs a hex prefix covers, as an inclusive range. uuid values
* order bytewise, i.e. by their hex digits, so prefix+"000…" .. prefix+"fff…"
* is exactly the set that starts with the prefix.
*/
export function uuidRangeForPrefix(prefix: string): [string, string] {
const fmt = (hex: string) => `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
return [fmt(prefix.padEnd(32, "0")), fmt(prefix.padEnd(32, "f"))];
}

/** Ids in the warm local vector index (empty when it is not loaded). */
function localIndexIds(): string[] {
try {
return getLocalVectorSearch().getScarIds();
} catch {
return [];
}
}
const HEX_PREFIX_RE = /^[0-9a-f]{4,32}$/i;

/**
Expand All @@ -58,10 +78,19 @@ async function resolveIdPrefix(input: string): Promise<{ id: string } | { error:
const prefix = input.toLowerCase();

if (hasSupabase() && isConfigured()) {
// Supabase: use PostgREST like filter
// GIT-119: this sent id=like.<prefix>% — Postgres has no LIKE on a UUID
// column (42883 "operator does not exist: uuid ~~ unknown"), so prefix
// archiving failed on every store. Resolve from the local index when it
// has the answer, else ask the store for the UUID range the prefix spans.
const local = localIndexIds().filter((id) => id.replace(/-/g, "").startsWith(prefix));
if (local.length === 1) return { id: local[0] };
if (local.length > 1) {
return { error: `Ambiguous prefix "${prefix}" — matches multiple learnings: ${local.slice(0, 2).map((m) => m.slice(0, 12) + "…").join(", ")}` };
}
const [low, high] = uuidRangeForPrefix(prefix);
const matches = await directQuery<{ id: string }>(getTableName("learnings"), {
select: "id",
filters: { id: `like.${prefix}%` },
filters: { and: `(id.gte.${low},id.lte.${high})` },
limit: 2,
});

Expand Down Expand Up @@ -130,10 +159,24 @@ export async function archiveLearning(params: ArchiveLearningParams): Promise<Ar
// updated_at (trigger) still records when it happened.
const learningsTable = getTableName("learnings");
const hasArchivedAt = (await supportedColumns(learningsTable, ["archived_at"])).has("archived_at");
await directPatch(learningsTable, { id: `eq.${resolvedId}` }, {
const patched = await directPatch(learningsTable, { id: `eq.${resolvedId}` }, {
is_active: false,
...(hasArchivedAt && { archived_at: archivedAt }),
});
// GIT-119: a full UUID skips resolution, and PostgREST answers 200 with
// no rows when nothing matched — so this reported "Archived", durable,
// for a learning that does not exist.
if (patched.count === 0) {
const msg = `No learning ${resolvedId} in the store — nothing was archived`;
return {
...notStored(),
id: resolvedId,
cache_flushed: false,
display: wrapDisplay(msg),
error: msg,
performance_ms: timer.stop(),
};
}

try {
await flushCache();
Expand Down
15 changes: 10 additions & 5 deletions src/tools/session-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,15 @@ export function formatWriteWarnings(
return lines;
}

/**
* Store a session's embedding (PATCH, not upsert — the row already exists).
* GIT-119: 0 rows = not saved; throwing lets the effect tracker record it.
*/
export async function saveSessionEmbedding(sessionId: string, embeddingJson: string): Promise<void> {
const patched = await supabase.directPatch(getTableName("sessions"), { id: sessionId }, { embedding: embeddingJson });
if (patched.count === 0) throw new Error(`session ${sessionId.slice(0, 8)}: embedding PATCH updated no row`);
}

/**
* GIT-21: Clean up all session files for a closed session.
* Unregisters from registry, deletes per-session directory, and removes legacy file.
Expand Down Expand Up @@ -1566,11 +1575,7 @@ export async function sessionClose(
const embeddingVector = await embed(embeddingText);
if (embeddingVector) {
const embeddingJson = JSON.stringify(embeddingVector);
// Update session with embedding (PATCH, not upsert — row already exists)
await supabase.directPatch(getTableName("sessions"),
{ id: sessionId },
{ embedding: embeddingJson }
);
await saveSessionEmbedding(sessionId, embeddingJson);
console.error("[session_close] Embedding saved to session");

// Phase 5: Implicit thread detection (chained after embedding)
Expand Down
7 changes: 5 additions & 2 deletions src/tools/session-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,8 @@ async function createSessionRecord(
* Fire-and-forget — failures logged but don't block session_start.
* Only sets close_compliance if it's currently null (truly abandoned).
*/
async function markSessionSuperseded(oldSessionId: string, newSessionId: string): Promise<void> {
/** @internal exported for the GIT-119 zero-row test. */
export async function markSessionSuperseded(oldSessionId: string, newSessionId: string): Promise<void> {
if (!hasSupabase()) return; // Free tier: no remote session tracking
try {
// Check if session already has close_compliance (was properly closed)
Expand All @@ -490,7 +491,7 @@ async function markSessionSuperseded(oldSessionId: string, newSessionId: string)
// Already closed — don't overwrite
return;
}
await supabase.directPatch(getTableName("sessions"),
const patched = await supabase.directPatch(getTableName("sessions"),
{ id: oldSessionId },
{
close_compliance: {
Expand All @@ -500,6 +501,8 @@ async function markSessionSuperseded(oldSessionId: string, newSessionId: string)
},
}
);
// GIT-119: 0 rows = the superseded row isn't there; not marked.
if (patched.count === 0) throw new Error(`no session row ${oldSessionId.slice(0, 8)} to mark superseded`);
console.error(`[session_start] Marked session ${oldSessionId.slice(0, 8)} as superseded by ${newSessionId.slice(0, 8)}`);
} catch (error) {
console.error(`[session_start] Failed to mark session ${oldSessionId.slice(0, 8)} as superseded:`, error);
Expand Down
29 changes: 29 additions & 0 deletions tests/e2e/blank-supabase.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,25 @@ async function flow() {
await waitQuiet(netlog, { minMs: 2000, quietMs: 3000 });
const health = await step("health", { failure_limit: 20 });

// GIT-119: archive_learning by id prefix sent id=like.<prefix>% against a
// uuid column (42883), and a full UUID that matched no row still reported
// "Archived". The row below is inserted after the server loaded its index,
// so the prefix must be resolved by the store (UUID range), not locally.
const archiveRowId = randomUUID();
await rest("POST", "gitmem_learnings", [{
id: archiveRowId, learning_type: "scar", title: `Driver archive target (${label})`,
description: "Inserted after startup so archive_learning resolves its prefix from the store.",
severity: "low", counter_arguments: ["You might think it is permanent — but it is archived here."],
project: PROJECT, is_active: true,
}]);
const archivePrefix = archiveRowId.slice(0, 8);
const ap = await step("archive_learning", { id: archivePrefix, reason: "venue driver (GIT-119)" });
const archivedRow = await rest("GET", `gitmem_learnings?select=is_active&id=eq.${archiveRowId}`, undefined, { Prefer: "" }).then((r) => r.json());
const archivePrefixDurable = /Archived learning/.test(ap) && archivedRow[0]?.is_active === false;
const ghostId = "00000000-0000-4000-8000-00000000d119";
const ag = await step("archive_learning", { id: ghostId, reason: "venue driver (GIT-119)" });
const archiveGhostHonest = !/Archived learning/.test(ag) && /nothing was archived|not found|No learning/i.test(ag);

// GIT-102: every WARN on the close names a subsystem and cause and says
// whether the session content was stored; health reports the write-path verdict.
const closeWarns = sc.replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter((l) => /^WARN /.test(l));
Expand Down Expand Up @@ -443,6 +462,10 @@ async function flow() {
session_close_persisted: closedRows.length === 1 && closedRows[0].closing_reflection != null,
missing_payload_named: missingPayloadNamed,
resolve_thread_durable: resolveDurable,
archive_prefix_durable: archivePrefixDurable,
archive_prefix_text: ap.slice(0, 300),
archive_ghost_honest: archiveGhostHonest,
archive_ghost_text: ag.slice(0, 300),
close_warns: closeWarns,
close_warns_self_describing: closeWarnsSelfDescribing,
health_has_write_path: healthHasWritePath,
Expand Down Expand Up @@ -624,6 +647,12 @@ if (edgeCalls.length) failureCheck.unexpected.push(...edgeCalls.map((c) => `edge
if (mode === "flow" && !result.resolve_thread_durable) {
failureCheck.unexpected.push("resolve_thread did not land durably in gitmem_threads, or claimed it did not (GIT-101)");
}
if (mode === "flow" && !result.archive_prefix_durable) {
failureCheck.unexpected.push(`archive_learning by id prefix did not archive the row (GIT-119): ${result.archive_prefix_text}`);
}
if (mode === "flow" && !result.archive_ghost_honest) {
failureCheck.unexpected.push(`archive_learning of a nonexistent id claimed success (GIT-119): ${result.archive_ghost_text}`);
}
if (mode === "flow" && !result.missing_payload_named) {
failureCheck.unexpected.push("session_close without a payload did not name the absolute closing-payload.json path (GIT-99)");
}
Expand Down
Loading
Loading