diff --git a/src/core/extract/receipt-writer.ts b/src/core/extract/receipt-writer.ts index be4961ae7..cecd8c332 100644 --- a/src/core/extract/receipt-writer.ts +++ b/src/core/extract/receipt-writer.ts @@ -85,11 +85,21 @@ const RUN_ID_SHORT_LEN = 8; /** * Truncate a run id to the standard 8-char short form used in slug * paths. Idempotent — passing an already-short id returns it unchanged. - * Non-hex / non-alphanumeric chars survive (op-checkpoint ids may - * include dashes or other separators). + * Non-hex / non-alphanumeric chars survive INSIDE the short form + * (op-checkpoint ids may include dashes or other separators), but + * boundary hyphens are trimmed (#3443): `slugifySegment()` strips + * leading/trailing hyphens during repo sync, so a short form like + * 'propose-' (from propose- run ids) made the DB receipt + * slug and its Git-backed slug disagree — writing the receipt through + * to the repo created a normalized sibling instead of materializing + * the existing page. Invariant: slugifySegment(shortRunId(x)) === + * shortRunId(x) for slug-safe run ids. */ export function shortRunId(runId: string): string { - return runId.slice(0, RUN_ID_SHORT_LEN); + // ponytail: truncation-based discrimination is only as good as the run id's + // first 8 chars; families that need per-run uniqueness must front-load it. + const short = runId.slice(0, RUN_ID_SHORT_LEN).replace(/^-+|-+$/g, ''); + return short || (runId ? 'run' : ''); } /** diff --git a/test/extract/receipt-writer.test.ts b/test/extract/receipt-writer.test.ts index 3de8ac76e..32a6e23a8 100644 --- a/test/extract/receipt-writer.test.ts +++ b/test/extract/receipt-writer.test.ts @@ -20,6 +20,7 @@ import { writeReceipt, type ExtractReceiptInput, } from '../../src/core/extract/receipt-writer.ts'; +import { slugifySegment } from '../../src/core/sync.ts'; const BASE_INPUT: ExtractReceiptInput = { kind: 'facts.conversation', @@ -81,6 +82,31 @@ describe('shortRunId / dateFromIso — pure helpers', () => { expect(shortRunId('op_check_abc')).toBe('op_check'); }); + // #3443 — a short form ending in '-' (e.g. propose- run ids) + // desynced the DB receipt slug from its Git-backed slug: slugifySegment() + // strips boundary hyphens during repo sync, so the write-through created a + // normalized sibling instead of materializing the existing page. + test('shortRunId is canonical under slugifySegment for every receipt-producing run-id family (#3443)', () => { + const familyRunIds = [ + 'propose-20260724103000-ab12cd34', // cycle/propose-takes.ts + `atoms-${Date.now().toString(36)}-pers`, // cycle/extract-atoms.ts + `efacts-${Date.now().toString(36)}-pers`, // cycle/extract-facts.ts + `concepts-${Date.now().toString(36)}`, // cycle/synthesize-concepts.ts + `ecf-${Date.now().toString(36)}-pers`, // extract-conversation-facts.ts + ]; + for (const runId of familyRunIds) { + const short = shortRunId(runId); + expect(slugifySegment(short)).toBe(short); + expect(short.length).toBeGreaterThan(0); + } + }); + + test('shortRunId trims boundary hyphens introduced by truncation', () => { + expect(shortRunId('propose-20260724103000-ab12cd34')).toBe('propose'); + // Pathological all-separator prefix still yields a non-empty segment. + expect(shortRunId('--------tail')).toBe('run'); + }); + test('dateFromIso extracts YYYY-MM-DD prefix', () => { expect(dateFromIso('2026-05-27T14:30:00Z')).toBe('2026-05-27'); expect(dateFromIso('2026-05-27T14:30:00.123456Z')).toBe('2026-05-27');