diff --git a/.gitignore b/.gitignore index ac4a13664..02132823d 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ terminal-emulator-vendored/build/ # Built React UI bundle (copied from desktop build) — except placeholder app/src/main/assets/web/* !app/src/main/assets/web/index.html +.superpowers/ diff --git a/app/src/main/kotlin/com/youcoded/app/parser/TranscriptWatcher.kt b/app/src/main/kotlin/com/youcoded/app/parser/TranscriptWatcher.kt index 198af8505..8d87b4067 100644 --- a/app/src/main/kotlin/com/youcoded/app/parser/TranscriptWatcher.kt +++ b/app/src/main/kotlin/com/youcoded/app/parser/TranscriptWatcher.kt @@ -24,7 +24,6 @@ import java.util.concurrent.ConcurrentHashMap * to read only new content, and deduplicate by uuid. */ class TranscriptWatcher( - private val projectsDir: File, // e.g., $HOME/.claude/projects/ private val scope: CoroutineScope, ) { companion object { @@ -49,21 +48,6 @@ class TranscriptWatcher( result = ANSI_REGEX.replace(result, "") return result.trim() } - - /** - * Convert a working directory path to Claude Code's project slug. - * Mirrors desktop's cwdToProjectSlug(): replace \, :, /, and space with -. - * Leading dash is preserved. Space handling is required — CC encodes spaces - * as dashes too, and without it the watcher reads from a non-existent - * directory for any cwd containing spaces (e.g. "PAF 540 Final Data Project"). - */ - fun cwdToProjectSlug(cwdPath: String): String { - return cwdPath - .replace('\\', '-') - .replace(':', '-') - .replace('/', '-') - .replace(' ', '-') - } } private val _events = MutableSharedFlow(extraBufferCapacity = 1000) diff --git a/app/src/main/kotlin/com/youcoded/app/runtime/CcProjectSlug.kt b/app/src/main/kotlin/com/youcoded/app/runtime/CcProjectSlug.kt new file mode 100644 index 000000000..87a3d0ffa --- /dev/null +++ b/app/src/main/kotlin/com/youcoded/app/runtime/CcProjectSlug.kt @@ -0,0 +1,37 @@ +package com.youcoded.app.runtime + +/** + * Mirrors Claude Code 2.1.229's ~/.claude/projects// encoding, + * bug-for-bug: every non-alphanumeric → '-'; slugs over 200 chars truncate + * and append base36(abs(rolling hash of the ORIGINAL path)). + * Anchored to CC-generated fixtures (CcProjectSlugTest) — never to the + * desktop TS implementation. Spec: + * youcoded-dev/docs/active/specs/2026-08-11-project-slug-encoding-repair.md §5.3. + */ +object CcProjectSlug { + // WHY caveat (final review, MINOR fold): Kotlin's Regex.replace iterates + // per Unicode CODE POINT; JS's String.replace(/[^a-zA-Z0-9]/g, ...) (the + // mirror this class mirrors) iterates per UTF-16 CODE UNIT. For any + // non-BMP character (e.g. emoji) that's two different answers — JS emits + // TWO replacement dashes (one per surrogate half), Kotlin emits ONE. This + // is currently UNREACHABLE: the only input this object ever receives is + // Android's canonicalHome, which is ASCII. Do NOT "fix" this to force + // agreement without a real non-ASCII cwd forcing the question — see + // slug-encoding.ts's own version note for the sibling mirror's anchor. + private const val MAX = 200 + private val NON_ALNUM = Regex("[^a-zA-Z0-9]") + + fun hash(s: String): String { + var h = 0 + for (c in s) h = (h shl 5) - h + c.code // Int wraparound == JS's |0 + // JS Math.abs on an int32 is exact (doubles); Kotlin abs(Int.MIN_VALUE) + // stays NEGATIVE. Widen to Long BEFORE abs or the mirror breaks at + // exactly int32-min. (Desktop's slug-encoding.ts documents the JS side.) + return kotlin.math.abs(h.toLong()).toString(36) + } + + fun slug(cwd: String): String { + val s = NON_ALNUM.replace(cwd, "-") + return if (s.length <= MAX) s else s.substring(0, MAX) + "-" + hash(cwd) + } +} diff --git a/app/src/main/kotlin/com/youcoded/app/runtime/SessionRegistry.kt b/app/src/main/kotlin/com/youcoded/app/runtime/SessionRegistry.kt index 405102105..ef4c10c39 100644 --- a/app/src/main/kotlin/com/youcoded/app/runtime/SessionRegistry.kt +++ b/app/src/main/kotlin/com/youcoded/app/runtime/SessionRegistry.kt @@ -50,8 +50,7 @@ class SessionRegistry { model = model, ) - val projectsDir = File(bootstrap.homeDir, ".claude/projects") - val transcriptWatcher = TranscriptWatcher(projectsDir, scope) + val transcriptWatcher = TranscriptWatcher(scope) val session = ManagedSession( id = sessionId, diff --git a/app/src/main/kotlin/com/youcoded/app/runtime/SessionService.kt b/app/src/main/kotlin/com/youcoded/app/runtime/SessionService.kt index b2f5726f8..e708b6158 100644 --- a/app/src/main/kotlin/com/youcoded/app/runtime/SessionService.kt +++ b/app/src/main/kotlin/com/youcoded/app/runtime/SessionService.kt @@ -640,10 +640,14 @@ class SessionService : Service() { fun destroySession(sessionId: String) { // Push this session's JSONL to all backends before destroying // (mirrors desktop main.ts session-exit → syncService.pushSession) + // Capture the hook-supplied transcript path BEFORE sessionRegistry.destroySession() + // tears down the PTY bridge/EventBridge below — same design as desktop's + // watcher: no derivation can be wrong about a path CC handed us. + val transcriptPath = sessionRegistry.sessions.value[sessionId]?.ptyBridge?.getEventBridge()?.getTranscriptPath(sessionId) syncService?.let { sync -> serviceScope.launch { try { - sync.pushSession(sessionId) + sync.pushSession(sessionId, transcriptPath) } catch (e: Exception) { android.util.Log.w("SessionService", "Session-end sync failed for $sessionId: $e") } diff --git a/app/src/main/kotlin/com/youcoded/app/runtime/SyncService.kt b/app/src/main/kotlin/com/youcoded/app/runtime/SyncService.kt index 01026af49..349f6a21c 100644 --- a/app/src/main/kotlin/com/youcoded/app/runtime/SyncService.kt +++ b/app/src/main/kotlin/com/youcoded/app/runtime/SyncService.kt @@ -220,26 +220,26 @@ class SyncService( } // ========================================================================= - // Slug Generation (CRITICAL — must match Claude Code's algorithm) + // Slug generation — ONE rule: CC's real encoding, mirrored by CcProjectSlug + // (fixture-anchored; desktop mirror: slug-encoding.ts). The old 4-char + // replace rule was deleted 2026-08-12: it named directories CC never + // writes (the home path contains dots), which silently killed session-end + // push, /resume aggregation, cross-device symlinking, AND the + // conversation-index stamp it fed (spec §5.3 — the index slug is resolved + // as a real path on restore, so it must be the real CC slug too). // ========================================================================= - /** - * Generate the current device's project slug. - * Must match TranscriptWatcher.cwdToProjectSlug() and desktop's getCurrentSlug(). - * Replace /, \, :, and space with - to match Claude Code's slug algorithm. - */ - fun getCurrentSlug(): String { - // Use canonical path to resolve symlinks (e.g., /data/user/0 → /data/data) - val homePath = try { - bootstrap.homeDir.canonicalPath - } catch (_: Exception) { - bootstrap.homeDir.absolutePath - } - // Replace path separators + spaces with dashes — must match desktop exactly. - // Space handling is required: CC encodes spaces as dashes too. - return homePath.replace('/', '-').replace('\\', '-').replace(':', '-').replace(' ', '-') + private fun canonicalHome(): String = try { + // Canonical path resolves symlinks (/data/user/0 → /data/data) — the + // same realpath step CC itself applies before slugging. + bootstrap.homeDir.canonicalPath + } catch (_: Exception) { + bootstrap.homeDir.absolutePath } + /** CC's real directory name for the home project. */ + fun ccHomeSlug(): String = CcProjectSlug.slug(canonicalHome()) + // ========================================================================= // Device Name (for conversation-index.json) // ========================================================================= @@ -1273,7 +1273,7 @@ class SyncService( } ?: JSONObject().apply { put("version", 1); put("sessions", JSONObject()) } val sessions = index.getJSONObject("sessions") - val slug = getCurrentSlug() + val slug = ccHomeSlug() val device = getDeviceName() val now = System.currentTimeMillis() val pruneThreshold = now - INDEX_PRUNE_DAYS * 24L * 60 * 60 * 1000 @@ -1381,7 +1381,7 @@ class SyncService( val projectsDir = File(claudeDir, "projects") if (!projectsDir.isDirectory) return - val currentSlug = getCurrentSlug() + val currentSlug = ccHomeSlug() projectsDir.listFiles()?.forEach { slugDir -> if (slugDir.name == currentSlug) return@forEach @@ -1411,7 +1411,7 @@ class SyncService( val projectsDir = File(claudeDir, "projects") if (!projectsDir.isDirectory) return - val currentSlug = getCurrentSlug() + val currentSlug = ccHomeSlug() val homeDir = File(projectsDir, currentSlug) if (!homeDir.isDirectory) return @@ -1519,11 +1519,18 @@ class SyncService( // Session-End Push // ========================================================================= - /** Push a single session's JSONL to all backends (called on session close). */ - suspend fun pushSession(sessionId: String) { - val slug = getCurrentSlug() - val jsonlFile = File(claudeDir, "projects/$slug/$sessionId.jsonl") + /** Push a single session's JSONL to all backends (called on session close). + * Prefers the hook-supplied transcript path (EventBridge) — same design as + * desktop's watcher: no derivation can be wrong about a path CC handed us. */ + suspend fun pushSession(sessionId: String, transcriptPath: String? = null) { + val fromHook = transcriptPath?.let { java.io.File(it) }?.takeIf { it.isFile } + val jsonlFile = fromHook ?: java.io.File(claudeDir, "projects/${ccHomeSlug()}/$sessionId.jsonl") if (!jsonlFile.exists()) return + // Remote bucket = the transcript's REAL containing dir name (CC wrote + // it), keeping session-end push consistent with the bulk push, which + // mirrors projects/ dirs verbatim. Fallback covers a hook path outside + // projects/ (should not happen; belt and suspenders). + val slug = jsonlFile.parentFile?.name ?: ccHomeSlug() // Update conversation index first updateConversationIndex() diff --git a/app/src/test/kotlin/com/youcoded/app/parser/CwdToProjectSlugTest.kt b/app/src/test/kotlin/com/youcoded/app/parser/CwdToProjectSlugTest.kt deleted file mode 100644 index 14615a12b..000000000 --- a/app/src/test/kotlin/com/youcoded/app/parser/CwdToProjectSlugTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.youcoded.app.parser - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Must mirror desktop's `cwdToProjectSlug` in transcript-watcher.ts. Any drift - * here means the Android transcript watcher points at a non-existent directory - * and chat view stays empty for the whole session. - */ -class CwdToProjectSlugTest { - - @Test - fun `encodes a Windows path without spaces`() { - assertEquals( - "C--Users-alice-repo", - TranscriptWatcher.cwdToProjectSlug("C:\\Users\\alice\\repo"), - ) - } - - @Test - fun `encodes a POSIX path without spaces`() { - assertEquals( - "-home-alice-repo", - TranscriptWatcher.cwdToProjectSlug("/home/alice/repo"), - ) - } - - // Regression: CC itself encodes spaces as dashes, so folders like - // "PAF 540 Final Data Project" must resolve to "PAF-540-Final-Data-Project". - @Test - fun `encodes spaces as dashes to match CC on Windows`() { - assertEquals( - "C--Users-alice-PAF-540-Final-Data-Project", - TranscriptWatcher.cwdToProjectSlug("C:\\Users\\alice\\PAF 540 Final Data Project"), - ) - } - - @Test - fun `encodes spaces as dashes to match CC on POSIX`() { - assertEquals( - "-home-alice-My-Project", - TranscriptWatcher.cwdToProjectSlug("/home/alice/My Project"), - ) - } -} diff --git a/app/src/test/kotlin/com/youcoded/app/runtime/CcProjectSlugTest.kt b/app/src/test/kotlin/com/youcoded/app/runtime/CcProjectSlugTest.kt new file mode 100644 index 000000000..f70d226c3 --- /dev/null +++ b/app/src/test/kotlin/com/youcoded/app/runtime/CcProjectSlugTest.kt @@ -0,0 +1,65 @@ +package com.youcoded.app.runtime + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Pairs copied from desktop/tests/fixtures/cc-slug-pairs.json (CC 2.1.229 + * probe, 2026-08-12) — anchored to directories a REAL CC created, never to + * the desktop TS implementation. Regenerate both together (spec §7). */ +class CcProjectSlugTest { + @Test fun `fixture pairs`() { + // probe: _ and . + assertEquals( + "-home-destin-YouCoded-probe-under-score-and-dots", + CcProjectSlug.slug("/home/destin/YouCoded/probe/under_score.and.dots"), + ) + // probe: punctuation + assertEquals( + "-home-destin-YouCoded-probe-punct--x-----y---z", + CcProjectSlug.slug("/home/destin/YouCoded/probe/punct (x) + 'y' #z"), + ) + // probe: over-cap + assertEquals( + "-home-destin-YouCoded-probe-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-6bal0v", + CcProjectSlug.slug( + "/home/destin/YouCoded/probe/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ), + ) + // probe: symlink resolves to realpath + assertEquals( + "-home-destin-YouCoded-probe-real-target", + CcProjectSlug.slug("/home/destin/YouCoded/probe/real-target"), + ) + // harvest: the reporting folder (comma+ampersand) + assertEquals( + "-home-destin-YouCoded-Projects-PAF-574---Diversity--Ethics----Public-Change", + CcProjectSlug.slug("/home/destin/YouCoded/Projects/PAF 574 - Diversity, Ethics, & Public Change"), + ) + // harvest: plain + assertEquals("-home-destin", CcProjectSlug.slug("/home/destin")) + // harvest: hyphens are fixed points + assertEquals("-home-destin-youcoded-dev", CcProjectSlug.slug("/home/destin/youcoded-dev")) + // windows drive+backslash (synthetic, both rules agree) + assertEquals("C--Users-alice", CcProjectSlug.slug("C:\\Users\\alice")) + } + + @Test fun `android home path gets the dashed slug CC writes`() { + assertEquals( + "-data-data-com-youcoded-app-files-home", + CcProjectSlug.slug("/data/data/com.youcoded.app/files/home"), + ) + } + + @Test fun `hash matches the JS reference`() { + assertEquals("22ci", CcProjectSlug.hash("abc")) + assertEquals("0", CcProjectSlug.hash("")) + } + + @Test fun `int32-min hash does NOT go negative (the Kotlin-only trap)`() { + // JS Math.abs(-2147483648) = 2147483648; Kotlin abs(Int.MIN_VALUE) is + // NEGATIVE. The impl must widen to Long before abs. Pinned indirectly: + assertEquals("zik0zk", kotlin.math.abs(Int.MIN_VALUE.toLong()).toString(36)) + } +} diff --git a/desktop/src/main/artifacts/projects-index.ts b/desktop/src/main/artifacts/projects-index.ts index de1aed98b..e14f269fb 100644 --- a/desktop/src/main/artifacts/projects-index.ts +++ b/desktop/src/main/artifacts/projects-index.ts @@ -23,7 +23,7 @@ import { canonicalize } from '../../shared/artifacts/canonicalize'; import { readFolders, type SavedFolder } from '../saved-folders'; import { getManagedRoots } from '../sync-spaces/service'; import { listPastSessions } from '../session-browser'; -import { cwdToProjectSlug } from '../transcript-watcher'; +import { ccProjectSlug } from '../slug-encoding'; const CLAUDE_DIR = path.join(os.homedir(), '.claude'); @@ -131,8 +131,7 @@ export async function listProjectsIndex(opts?: { withCounts?: boolean }): Promis // Conversation counts: a single global session scan, bucketed by CC slug. // Only when requested — listPastSessions is heavier (global), and ChatView's // frequent cwd-resolution calls don't need it. - const ccSlug = (projectPath: string) => - cwdToProjectSlug(projectPath.replace(/^([a-z]):/, (_m, d) => `${d.toUpperCase()}:`)); + const ccSlug = ccProjectSlug; let convBySlug: Map | null = null; if (opts?.withCounts) { const sessions = await listPastSessions(); diff --git a/desktop/src/main/chatsearch-index/index-service.ts b/desktop/src/main/chatsearch-index/index-service.ts index 0a2da8c5b..fc6628f36 100644 --- a/desktop/src/main/chatsearch-index/index-service.ts +++ b/desktop/src/main/chatsearch-index/index-service.ts @@ -18,8 +18,7 @@ import { } from '../conversations/service'; import { getTagRegistry } from '../conversations/tag-registry-service'; import { NativeHome } from '../native-home'; -import { cwdToProjectSlug } from '../transcript-watcher'; -import { ccProjectSlug } from '../project-conversations'; +import { nativeStoreSlug, ccProjectSlug } from '../slug-encoding'; import { laneMatches } from '../conversations/lane-guards'; import { buildMetaFile } from './meta-builder'; import { @@ -238,11 +237,12 @@ async function refreshFromLiveState(): Promise { provider: 'native', lane: 'native', records: nativeRecords, - // RAW slug, not ccProjectSlug — the two encodings diverge deliberately - // (ccProjectSlug uppercases a lowercase Windows drive letter). + // RAW frozen app-private slug, not ccProjectSlug — the two encodings + // diverge deliberately (ccProjectSlug uppercases a lowercase Windows + // drive letter). resolveTranscriptPath: (r) => resolveTranscriptPathTwoStep( r, - path.join(home.root, 'sessions', cwdToProjectSlug(r.originalPath), `${r.id}.jsonl`), + path.join(home.root, 'sessions', nativeStoreSlug(r.originalPath), `${r.id}.jsonl`), storeRoot, ), }, diff --git a/desktop/src/main/conversations/reconciler.ts b/desktop/src/main/conversations/reconciler.ts index 02e24384d..f84b9c020 100644 --- a/desktop/src/main/conversations/reconciler.ts +++ b/desktop/src/main/conversations/reconciler.ts @@ -6,7 +6,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { readSessionTranscriptMeta } from '../session-browser'; -import { ccProjectSlug } from '../project-conversations'; +import { ccProjectSlug } from '../slug-encoding'; import { transcriptSkipReason, MIN_TRANSCRIPT_BYTES } from './lane-guards'; import type { ConversationStore } from './conversation-store'; import type { ConversationRecord } from './store-core'; @@ -45,7 +45,14 @@ export interface ReconcileOpts { function buildSlugToName(knownFolders: string[] | undefined): Map { const m = new Map(); for (const folder of knownFolders ?? []) { - try { m.set(ccProjectSlug(folder).toLowerCase(), path.basename(folder)); } + try { + // CC slugs realpath(cwd) (see slug-encoding.ts fixture "symlink resolves to + // realpath"). Resolve the same way, falling back exactly as CC's Px() does, + // so a symlinked project folder finds CC's real directory. + let resolved: string; + try { resolved = fs.realpathSync.native(folder); } catch { resolved = folder; } + m.set(ccProjectSlug(resolved).toLowerCase(), path.basename(folder)); + } catch { /* unslugifiable path — skip */ } } return m; @@ -64,7 +71,7 @@ function resolveProjectName( // LAST-RESORT fallback used only when the folder is NOT among this device's // known folders (see buildSlugToName/resolveProjectName, which recover the exact // basename first). A CC slug is the cwd with separators flattened to '-' -// (cwdToProjectSlug); the original path is not recoverable from the slug alone, +// (ccProjectSlug); the original path is not recoverable from the slug alone, // so this takes the LAST slug segment — a TRUNCATION for hyphenated names // ('...-youcoded-dev' → 'dev'). Acceptable as a fallback because it's internally // consistent (transcriptRef and the mirror key use the same string) and the live diff --git a/desktop/src/main/conversations/service.ts b/desktop/src/main/conversations/service.ts index b93aed422..eeda9d0bd 100644 --- a/desktop/src/main/conversations/service.ts +++ b/desktop/src/main/conversations/service.ts @@ -13,9 +13,12 @@ import { NativeHome } from '../native-home'; import type { ConversationRecord, PortableModelRef } from './store-core'; import { mirrorIn, materializeOut } from './transcript-mirror'; import { reconcile } from './reconciler'; +// Fix (fork hold): read from the leaf module, NOT from ./slug-repair — that +// module imports getConversationStore from THIS file, so importing it back +// here would be a cycle. See heldForkIds' WHY in slug-repair-state.ts. +import { heldForkIds } from './slug-repair-state'; import { laneMatches } from './lane-guards'; -import { ccProjectSlug } from '../project-conversations'; -import { cwdToProjectSlug } from '../transcript-watcher'; +import { ccProjectSlug, nativeStoreSlug } from '../slug-encoding'; import { onSyncSpacesEvent, syncSpacesSyncNow, syncSpacesSyncNowAwaited, getManagedRoots } from '../sync-spaces/service'; import { readFolders } from '../saved-folders'; import { resolveLocalProject } from './resolve-local-project'; @@ -91,6 +94,32 @@ let nativeHomeRootOpt: string | undefined; let device = ''; let unsubscribe: (() => void) | null = null; let reconcileTimer: NodeJS.Timeout | null = null; +// Fix: the slug repair (spec §6) must not race the sweeps — see the WHY at the +// pauseSweeps export. While paused, any trigger (startup kick, the 30-min tick, +// a Personal 'synced' event) records a pending request instead of running. +// pauseDepth is a COUNTER, not a flag (review fix, MINOR): a second pauser +// calling pauseSweeps() while the first pause is still active must not have +// its resumeSweeps() lift the gate out from under the first caller — only +// the pauser that brings the depth back to 0 actually resumes. +let pauseDepth = 0; +let reconcilePending = false; +let materializePending = false; +// Fix: called by main.ts around the one-shot slug repair, BEFORE it runs. +// WHY: the reconciler snapshots every record at its start (reconciler.ts +// list() preload) and then walks directories for seconds; if the repair +// rewrites records / moves files mid-walk, that stale snapshot mirrors files +// back into buckets the repair just retired, and a concurrent materialize +// sweep re-creates a quarantined local copy from the pre-repair space record. +// Observed 2026-08-15 on real data: 8 space files + 1 local file resurrected +// this way. Pausing turns those triggers into one deferred run AFTER the +// repair, so the sweeps operate on repaired records instead of stale ones. +export function pauseSweeps(): void { pauseDepth++; } +export function resumeSweeps(): void { + if (pauseDepth > 0) pauseDepth--; + if (pauseDepth > 0) return; // still paused by another caller + if (reconcilePending) { reconcilePending = false; runReconcile(); } + if (materializePending) { materializePending = false; void materializeSweep(); } +} // Desktop hook wiring resolves the CLAUDE session id before calling in, so the // map is keyed by claude id (matches the store's record id). cwd is learned via // noteSessionStarted; events for never-announced sessions still upsert (the live @@ -122,6 +151,11 @@ export function emitConversationMetaChanged(): void { export async function startConversationStore(opts?: { conversationsRoot?: string; projectsDir?: string; topicsDir?: string; device?: string; nativeHomeRoot?: string; // tests only — production reads ~/.youcoded + // Fix: main.ts passes true so the startup reconcile/materialize kicks below + // land as PENDING instead of running, giving the one-shot slug repair a + // clean window before the sweeps see any records. Default false/absent + // keeps every existing caller's behavior unchanged. + pauseSweeps?: boolean; }): Promise { // Idempotent start (review fix 4): a second start without a stop would leak // the first onSyncSpacesEvent subscription (duplicate materialize sweeps @@ -157,6 +191,11 @@ export async function startConversationStore(opts?: { if (e.type === 'synced' && e.spaceId === 'personal' && e.updated) void materializeSweep(); }); + // Fix: pause BEFORE the detached kicks below, so the caller's post-repair + // resumeSweeps() sees this run's startup reconcile/materialize as pending + // work rather than having already fired against pre-repair records. + if (opts?.pauseSweeps) pauseSweeps(); + // Carry-forward 2: kick the reconciler DETACHED. The first-ever run mirrors // potentially GBs of transcripts (serial copies); awaiting it here would block // app startup. runReconcile swallows its own failures. @@ -197,6 +236,14 @@ export function stopConversationStore(): void { for (const t of pendingActivity.values()) clearTimeout(t); pendingActivity.clear(); sessions.clear(); + // Fix (review, MINOR): this module is a true singleton (idempotent restart + // calls this first — see startConversationStore's comment) — a pause left + // dangling from a PRIOR store's slug-repair run (or a caller that paused + // and never resumed) must not silently carry into the next start and stick + // every future sweep trigger in "pending" forever. + pauseDepth = 0; + reconcilePending = false; + materializePending = false; // WHY: only settle pending meta writes when a store was ACTUALLY running. // startConversationStore calls this unconditionally first (idempotent // teardown) even on the very first-ever start, when writes may already be @@ -300,14 +347,15 @@ export function containedTranscriptPath(root: string, ref: string): string | nul } // The on-disk transcript path for this session, on THIS device. // 'claude' -> ~/.claude/projects//.jsonl (CC's own convention). -// 'native' -> ~/.youcoded/sessions//.jsonl — mirrors -// NativeHome's private sessionPath() exactly (raw slug, NOT ccProjectSlug's -// drive-letter uppercasing — see harness/session-store.ts's slug-divergence -// comment for why the two deliberately diverge). +// 'native' -> ~/.youcoded/sessions//.jsonl — mirrors +// NativeHome's private sessionPath() exactly (the FROZEN app-private rule — +// raw slug, NOT ccProjectSlug's drive-letter uppercasing — see +// harness/session-store.ts's slug-divergence comment for why the two +// deliberately diverge). function localJsonlPath(cwd: string, sessionId: string, sessionProvider: SessionProvider): string { if (sessionProvider === 'native') { const home = new NativeHome(nativeHomeRootOpt); - return path.join(home.root, 'sessions', cwdToProjectSlug(cwd), `${sessionId}.jsonl`); + return path.join(home.root, 'sessions', nativeStoreSlug(cwd), `${sessionId}.jsonl`); } return path.join(projectsDir, ccProjectSlug(cwd), `${sessionId}.jsonl`); } @@ -503,6 +551,9 @@ async function listAllProviders(s: ConversationStore): Promise { + // Fix: quiesced for the slug repair — see pauseSweeps' WHY. Return before + // any I/O; resumeSweeps() re-fires this exact call once the pause lifts. + if (pauseDepth > 0) { materializePending = true; return; } // Capture the store (review fix 3): stop() mid-sweep nulls the module field, // and every use below an await would otherwise become a swallowed TypeError. const s = store; @@ -515,8 +566,13 @@ async function materializeSweep(): Promise { ); let saved: Array<{ path: string }> = []; try { saved = readFolders(); } catch { /* saved folders unreadable */ } + // Fix (fork hold): read ONCE per sweep, not per record — a surfaced fork + // (slug-repair.ts §6.0 Case C) must be frozen out of this direction until a + // human resolves it; see heldForkIds' WHY in slug-repair-state.ts. + const heldForks = heldForkIds(); for (const rec of records) { if (!rec.transcriptRef) continue; // no durable copy to materialize from + if (heldForks.has(rec.id)) continue; // fork hold — frozen until resolved // The record IS the truth for provider (not a param) — see // asSessionProvider's comment. const sessionProvider = asSessionProvider(rec.provider); @@ -592,6 +648,16 @@ export function noteSessionEnded(claudeSessionId: string): void { // so it resolves the project via resolveLocalProject. export async function materializeOne(id: string, cwd?: string): Promise { const s = store; if (!s) return; + // Fix (fork hold): a surfaced fork must be frozen out of this direction + // until a human resolves it — see heldForkIds' WHY in slug-repair-state.ts. + // Checked before any I/O (quiescence wait, project resolution) below. + if (heldForkIds().has(id)) { + // Fix (review, MINOR): a takeover attempt silently doing nothing against + // a held session was undiagnosable — this line makes it visible that the + // no-op was the hold, not a bug. + log('INFO', 'ConversationStore', 'materialize skipped: fork held', { id }); + return; + } // Task 8: try 'claude' first, then 'native' — a UUID can't legitimately // exist in both buckets, so the first hit IS the record (no need to read // both on the common path). Each lookup is isolated: a rejecting get() on @@ -697,6 +763,9 @@ export async function flushSessionToSpace(claudeSessionId: string): Promise 0) { reconcilePending = true; return; } if (!store) return; const s = store; // Known folders let the reconciler recover the EXACT project name for a CC slug @@ -709,11 +778,16 @@ function runReconcile(): void { ]; try { knownFolders.push(...readFolders().map((f) => f.path)); } catch { /* saved folders unreadable — managed projects still cover most cases */ } + // Fix (fork hold): read ONCE per reconcile run, not per mirror() call — a + // surfaced fork must be frozen out of the local->space direction too; see + // heldForkIds' WHY in slug-repair-state.ts. + const heldForks = heldForkIds(); reconcile({ projectsDir, topicsDir, store: s, device, knownFolders, // Production mirror closure: the reconciler stays free of transcript-mirror // + the Conversations root. Best-effort — a throw here must not abort the scan. mirror: (localPath: string, projectKey: string, sessionId: string) => { + if (heldForks.has(sessionId)) return; // fork hold — frozen until resolved try { // WHY hardcoded 'claude': the reconciler scans ~/.claude/projects only // — it is CC-only by definition, not a stopgap (reconciler.ts:115,182,188 diff --git a/desktop/src/main/conversations/slug-repair-state.ts b/desktop/src/main/conversations/slug-repair-state.ts new file mode 100644 index 000000000..a6fc05dad --- /dev/null +++ b/desktop/src/main/conversations/slug-repair-state.ts @@ -0,0 +1,102 @@ +// Leaf module — the slug repair runner's on-disk state shape, plus the +// fork-hold reader BOTH slug-repair.ts (the runner) and conversations/ +// service.ts (the mirror sweeps) need. No imports beyond fs/path/os on +// purpose: service.ts importing slug-repair.ts directly would create a +// CYCLE (slug-repair.ts imports getConversationStore from ./service), so +// this tiny module is the shared seam both sides import from instead. +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// A held fork's id, plus the exact absolute paths its snapshot/finding named +// at the moment it was (re)surfaced (review fix, IMPORTANT 2 — auto-release +// on absence of evidence). Recording paths lets the runner tell "user +// resolved the fork" (a recorded path no longer exists on disk) apart from +// "this run's scan simply didn't reach the pair" (e.g. a knownFolders miss, +// or readFolders() throwing) — the latter must NOT release the hold, or +// materializeSweep can clobber the smaller fork copy within seconds of the +// hold silently dropping. +export interface SurfacedFork { + id: string; + paths: string[]; +} +export interface RepairState { + v: 1; + deferred: Record; + // True forks (spec §6.0 Case C) the repair has surfaced to the user and + // left on disk, untouched. See heldForkIds' WHY below for what holding a + // fork actually protects against. + surfacedForks: SurfacedFork[]; +} + +export function defaultStateFile(homeDir: string = os.homedir()): string { + // Test seam (review fix, MINOR): conversations/service.ts calls + // heldForkIds()/readState() with no stateFile override on the production + // code path, so a test that exercises that path with no seam reads the + // DEVELOPER'S REAL ~/.youcoded/slug-repair-state.json — non-hermetic, and + // whatever this machine's file happens to hold silently changes what the + // test observes. This env var is a test-only seam: it is never documented + // for users, ships in no user-facing config or docs, and production code + // never sets it — only tests do, to get a hermetic state file per run. + if (process.env.YOUCODED_SLUG_REPAIR_STATE) return process.env.YOUCODED_SLUG_REPAIR_STATE; + return path.join(homeDir, '.youcoded', 'slug-repair-state.json'); +} + +// Normalizes one raw surfacedForks entry: the pre-this-fix shape was a bare +// session-id string (no recorded paths); tolerate that on read so an +// existing state file doesn't get treated as corrupt. Anything unrecognized +// is dropped rather than guessed at — a bad entry silently disappearing is +// safer than a bad entry silently holding the wrong thing. +function normalizeSurfacedFork(entry: unknown): SurfacedFork | null { + if (typeof entry === 'string') return { id: entry, paths: [] }; + if (entry && typeof entry === 'object') { + const e = entry as { id?: unknown; paths?: unknown }; + if (typeof e.id === 'string') { + const paths = Array.isArray(e.paths) ? e.paths.filter((p): p is string => typeof p === 'string') : []; + return { id: e.id, paths }; + } + } + return null; +} + +/** Reads the state file, normalizing a missing/corrupt file, a + * pre-fork-hold file (no `surfacedForks` key), or a pre-paths-tracking file + * (`surfacedForks` as bare id strings) to the current shape. */ +export function readState(stateFile?: string): RepairState { + const file = stateFile ?? defaultStateFile(); + try { + const raw = JSON.parse(fs.readFileSync(file, 'utf8')) as { deferred?: Record; surfacedForks?: unknown }; + const surfacedForks = Array.isArray(raw.surfacedForks) + ? raw.surfacedForks.map(normalizeSurfacedFork).filter((f): f is SurfacedFork => f !== null) + : []; + return { + v: 1, + deferred: raw.deferred ?? {}, + surfacedForks, + }; + } catch { + return { v: 1, deferred: {}, surfacedForks: [] }; + } +} + +export function writeState(state: RepairState, stateFile?: string): void { + const file = stateFile ?? defaultStateFile(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(state)); +} + +/** WHY: a surfaced fork (spec §6.0 Case C — two diverged copies of one + * session, both preserved, never auto-resolved) must be immune to the + * size-gated mirrors in BOTH directions until a human resolves it. + * transcript-mirror.ts's materializeOut/mirrorIn are grow-only BY SIZE, not + * content-aware — for a fork, larger is not a superset. Without this hold, + * materializeOut's grow-only rule clobbers the smaller fork copy within + * seconds of the repair releasing the sweeps (observed 2026-08-15: 74 + * messages of session 26d919ff displaced into quarantine because the space + * copy of the fork happened to be larger than the project-dir copy), and the + * next launch sees two identical copies and silently stops surfacing the + * fork. Cheap on-disk read; returns an empty set on a missing/corrupt state + * file so a fresh install never treats "no state yet" as "everything held". */ +export function heldForkIds(stateFile?: string): Set { + return new Set(readState(stateFile).surfacedForks.map((f) => f.id)); +} diff --git a/desktop/src/main/conversations/slug-repair.ts b/desktop/src/main/conversations/slug-repair.ts new file mode 100644 index 000000000..2df358a46 --- /dev/null +++ b/desktop/src/main/conversations/slug-repair.ts @@ -0,0 +1,849 @@ +// One-time, idempotent startup repair for data mis-filed by the slug-encoding +// bug. Ground rules (spec §6.0): NEVER unlink (quarantine instead), NEVER +// union two transcripts (parentUuid chains make a merged file undefined +// behavior), classify every duplicate pair by CONTENT at run time, and a true +// fork (case C) is never automated — snapshot, surface, leave the disk alone. +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import crypto from 'crypto'; +import { ccProjectSlug, nativeStoreSlug } from '../slug-encoding'; +import { firstCwd, isForeignCwd } from '../transcript-cwd'; +import { readFolders } from '../saved-folders'; +import { getManagedRoots } from '../sync-spaces/service'; +import { getConversationStore } from './service'; +import { log } from '../logger'; +import { readState, writeState, defaultStateFile } from './slug-repair-state'; + +export function uuidSet(filePath: string): Set { + const out = new Set(); + let raw = ''; + try { raw = fs.readFileSync(filePath, 'utf8'); } catch { return out; } + for (const line of raw.split('\n')) { + if (!line.includes('"uuid"')) continue; + try { + const u = (JSON.parse(line) as { uuid?: unknown }).uuid; + if (typeof u === 'string') out.add(u); + } catch { /* corrupt line — skip */ } + } + return out; +} + +export function fileMd5(filePath: string): string { + return crypto.createHash('md5').update(fs.readFileSync(filePath)).digest('hex'); +} + +/** uuid -> md5 of that message's raw line bytes. Raw bytes (not the + * JSON.parse'd + re-stringified object) on purpose: CC transcripts are + * append-only, so a same-uuid byte difference between two copies of "the + * same" message means the line was truncated or corrupted in place, not + * that JSON key order/whitespace drifted — raw-byte hashing catches that, + * a semantic re-stringify would paper over it. */ +function uuidContentHashes(filePath: string): Map { + const out = new Map(); + let raw = ''; + try { raw = fs.readFileSync(filePath, 'utf8'); } catch { return out; } + for (const line of raw.split('\n')) { + if (!line.includes('"uuid"')) continue; + try { + const u = (JSON.parse(line) as { uuid?: unknown }).uuid; + if (typeof u === 'string') out.set(u, crypto.createHash('md5').update(line).digest('hex')); + } catch { /* corrupt line — skip */ } + } + return out; +} + +/** wrongCopy = the file in the WRONG location; correctCopy = the file where it + * belongs. Equal-uuid-different-bytes lands on 'wrong-is-subset' on purpose: + * the action (keep the correct-directory copy) is the same, and per-turn + * metadata lines legitimately differ between copies. */ +export function classifyPair(wrongCopy: string, correctCopy: string): + 'identical' | 'wrong-is-subset' | 'wrong-is-superset' | 'fork' { + if (fileMd5(wrongCopy) === fileMd5(correctCopy)) return 'identical'; + // Same-uuid content divergence check. Set-only comparison (uuid present in + // both files, ignore its bytes) silently blesses a truncated/corrupted + // shared message as a clean subset or superset — the copy with the intact + // version would then get quarantined right along with the truncated one. + // Fork is the safe direction here: quarantine preserves the disk state + // either way, but 'fork' routes the pair to a human instead of an + // automated keeper pick that might throw away the only good copy. + const wHashes = uuidContentHashes(wrongCopy); + const cHashes = uuidContentHashes(correctCopy); + for (const [u, h] of wHashes) { + const ch = cHashes.get(u); + if (ch !== undefined && ch !== h) return 'fork'; + } + const w = uuidSet(wrongCopy); + const c = uuidSet(correctCopy); + let wOnly = 0; for (const u of w) if (!c.has(u)) wOnly++; + let cOnly = 0; for (const u of c) if (!w.has(u)) cOnly++; + if (wOnly === 0) return 'wrong-is-subset'; + if (cOnly === 0) return 'wrong-is-superset'; + return 'fork'; +} + +/** Quarantine, not deletion. Lives under ~/.youcoded/ — NEVER inside + * ~/.claude/projects/: four subsystems readdir that tree and would adopt a + * quarantine folder as a project (spec §6.0). */ +export class Quarantine { + readonly homeRoot: string; + readonly dir: string; + constructor(homeRoot: string = os.homedir()) { + this.homeRoot = homeRoot; + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + this.dir = path.join(homeRoot, '.youcoded', 'repair-quarantine', stamp); + } + log(line: string): void { + fs.mkdirSync(this.dir, { recursive: true }); + fs.appendFileSync(path.join(this.dir, 'decisions.log'), `${new Date().toISOString()} ${line}\n`); + } + private destFor(absPath: string): string { + const rel = path.relative(this.homeRoot, absPath); + const dest = path.join(this.dir, rel); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + return dest; + } + /** MOVE out of the live tree (reversible). Returns false (and only logs) if + * the rename fails (e.g. EXDEV) — never falls back to copy+delete for + * anything that might hold real content. */ + move(absPath: string, why: string): boolean { + try { + fs.renameSync(absPath, this.destFor(absPath)); + this.log(`MOVE ${absPath} (${why})`); + return true; + } catch (e) { + // Adaptation (Task 17, disclosed — real defect found via TDD, not in + // the brief): retiring an EMPTIED directory whose files were quarantined + // one at a time moments earlier collides here. Each of those file-moves + // already created a directory at this exact home-relative path under + // quarantine, so renaming the now-empty source dir onto it throws + // ENOTEMPTY even though the source holds nothing of value anymore — the + // quarantine tree already has everything that was ever inside it. + // Verified-empty directories are the ONLY case this falls back to a + // plain rmdir for; anything with real content (a file, or a directory + // that still has entries) always hits the fail-closed SKIP-MOVE path. + if (this.isEmptyDir(absPath)) { + try { + fs.rmdirSync(absPath); + this.log(`RETIRE-EMPTY-DIR ${absPath} (${why}) — contents already quarantined individually`); + return true; + } catch { /* fall through to the SKIP-MOVE log below */ } + } + this.log(`SKIP-MOVE ${absPath} (${why}) — rename failed: ${String(e)}`); + return false; + } + } + private isEmptyDir(p: string): boolean { + try { return fs.statSync(p).isDirectory() && fs.readdirSync(p).length === 0; } + catch { return false; } + } + /** COPY a snapshot — for case C, where the live tree stays untouched. */ + snapshot(absPath: string, why: string): void { + try { + fs.copyFileSync(absPath, this.destFor(absPath)); + this.log(`SNAPSHOT ${absPath} (${why})`); + } catch (e) { + this.log(`SKIP-SNAPSHOT ${absPath} — copy failed: ${String(e)}`); + } + } +} + +export interface RepairOpts { + projectsDir: string; // ~/.claude/projects + homeDir: string; // os.homedir() + knownFolders: string[]; // saved folders + managed roots, absolute paths + quarantine: Quarantine; + liveMs?: number; // default LIVE_MTIME_MS + now?: () => number; // test seam + // Test seam for isForeignCwd/firstCwd's platform-relative foreign-cwd check + // (review fix: firstCwd gained an optional trailing platform param so POSIX + // fixtures don't silently only pass on POSIX CI runners) — default + // process.platform, threaded through to firstCwd below. + platform?: NodeJS.Platform; + // Session ids already held as surfaced forks from a prior run (fork-hold + // fix, found on the real-data run) — read once at the top of runSlugRepair + // and threaded through so the fork branches below can skip re-snapshotting + // an already-held pair. See heldForkIds' WHY in slug-repair-state.ts. + heldForks?: Set; +} +export interface RepairFinding { + sessionId: string; + homeFolder: string; // the R2 answer (the real project) + // 'rename-failed' (review fix): the promotion rename itself threw. Both + // physical copies survive (one at the $HOME path, or one in quarantine + + // one at $HOME) — this kind exists so a run failure never silently drops + // a finding, it just can't say where the session finally landed. + // 'record-repaired' (review fix, §6.2): the session's record was + // upserted but no file was renamed/moved — distinct from 'moved' so a + // consumer never treats `paths` as proof a physical relocation happened. + // 'record-repair-failed' (review fix, Minor 1): the §6.2 upsert itself + // threw AFTER any file move already succeeded — distinct from + // 'rename-failed', whose doc above means "the promotion rename threw". + // Reusing 'rename-failed' here read backwards: no rename failed, the + // record write did. A consumer branching on kind needs to know which + // half of the operation actually broke. + kind: 'moved' | 'quarantined' | 'replaced-with-superset' | 'fork-surfaced' | 'deferred-live' | 'rename-failed' | 'record-repaired' | 'record-repair-failed'; + paths: string[]; +} + +export const LIVE_MTIME_MS = 10 * 60 * 1000; // "live" = appended within 10 min (spec §6.5: mechanical, written down) + +function topLevelJsonl(dir: string): string[] { + try { + return fs.readdirSync(dir, { withFileTypes: true }) + // DIRECT CHILDREN ONLY — subagent transcripts live at + // /subagents/ below and travel with their parent (§6.1). + .filter(e => e.isFile() && e.name.endsWith('.jsonl')) + .map(e => path.join(dir, e.name)); + } catch { return []; } +} + +function isLive(file: string, liveMs: number, now: () => number): boolean { + try { return now() - fs.statSync(file).mtimeMs < liveMs; } catch { return true; } // unstat-able → treat as live (safe) +} + +// MINOR fold (final review): path.resolve normalizes '.'/'..' and separators +// but does NOT realpath — a saved folder reached through a symlink compares +// unequal to its target here, so the repair silently no-ops for it rather +// than misfiling anything. Safe direction; deliberate non-realpath (mirrors +// spec §5.1's symlink discussion for ccProjectSlug itself). +const sameDir = (a: string, b: string) => path.resolve(a) === path.resolve(b); + +export function repairHomeForks(opts: RepairOpts): RepairFinding[] { + const { projectsDir, homeDir, knownFolders, quarantine: q } = opts; + const liveMs = opts.liveMs ?? LIVE_MTIME_MS; + const now = opts.now ?? Date.now; + const platform = opts.platform ?? process.platform; + const findings: RepairFinding[] = []; + const homeSlugDir = path.join(projectsDir, ccProjectSlug(homeDir)); + + for (const file of topLevelJsonl(homeSlugDir)) { + const sessionId = path.basename(file, '.jsonl'); + if (isLive(file, liveMs, now)) { + findings.push({ sessionId, homeFolder: '', kind: 'deferred-live', paths: [file] }); + continue; + } + const cwd = firstCwd(file, platform); // R2 — NOT R1 (§6.1: R1 would + if (!cwd || isForeignCwd(cwd, platform)) continue; // call the fork a resident and no-op) + const P = knownFolders.find(p => sameDir(p, cwd)); + if (!P || sameDir(P, homeDir)) continue; + + const correctDir = path.join(projectsDir, ccProjectSlug(P)); + const correct = path.join(correctDir, path.basename(file)); + if (!fs.existsSync(correct)) { + // Review fix (Important 1) + MINOR fold (final review): every other + // mutation in this module is guarded; this promotion (mkdir + rename) + // wasn't. Fail closed — `file` never left until renameSync succeeds, + // so a failure at either step loses nothing, but an unguarded throw + // would still abort the whole run and drop every finding already + // collected. Cover BOTH the mkdir and the rename in one try, not just + // the rename — an ENOSPC/EACCES on directory creation is the same + // hazard. + try { + fs.mkdirSync(correctDir, { recursive: true }); + fs.renameSync(file, correct); + } catch (e) { + q.log(`ERROR ${sessionId}: move-to-correct rename failed — $HOME copy still at ${file}: ${String(e)}`); + findings.push({ sessionId, homeFolder: P, kind: 'rename-failed', paths: [file] }); + continue; + } + q.log(`MOVE-TO-CORRECT ${file} -> ${correct}`); + findings.push({ sessionId, homeFolder: P, kind: 'moved', paths: [correct] }); + continue; + } + switch (classifyPair(file, correct)) { + case 'identical': + case 'wrong-is-subset': + if (q.move(file, `6.1 ${sessionId}: $HOME copy ⊆ correct copy`)) { + findings.push({ sessionId, homeFolder: P, kind: 'quarantined', paths: [file] }); + } + break; + case 'wrong-is-superset': { + // Review fix (CRITICAL): this branch is the one place that touches + // the CORRECT-dir copy's inode (quarantine it, then rename `file` + // over its old path). If CC is actively appending to `correct`, + // doing that steals the inode out from under the open fd and loses + // turns — spec §6.5's exact forbidden hazard. Defer the whole pair + // instead of racing it; a live session will go quiet and get + // reclassified on a later run. + if (isLive(correct, liveMs, now)) { + findings.push({ sessionId, homeFolder: P, kind: 'deferred-live', paths: [file, correct] }); + break; + } + if (q.move(correct, `6.1 ${sessionId}: correct copy superseded`)) { + // Review fix (Important 1): guard the promotion rename. If it + // throws AFTER the quarantine move already succeeded, `correct` + // is briefly empty on disk — but nothing is LOST: the superseded + // copy is safe in quarantine and `file` is still at its original + // $HOME path. Log exactly where both are and surface a finding + // instead of throwing and losing the whole run. + try { + fs.renameSync(file, correct); + q.log(`MOVE-TO-CORRECT ${file} -> ${correct} (superset)`); + findings.push({ sessionId, homeFolder: P, kind: 'replaced-with-superset', paths: [correct] }); + } catch (e) { + const quarantinedAt = path.join(q.dir, path.relative(q.homeRoot, correct)); + q.log(`ERROR ${sessionId}: promotion rename failed after quarantine — superseded copy at ${quarantinedAt}, $HOME copy still at ${file}: ${String(e)}`); + findings.push({ sessionId, homeFolder: P, kind: 'rename-failed', paths: [file, quarantinedAt] }); + } + } + break; + } + case 'fork': + // Review fix (CRITICAL): snapshotting `correct` while CC is live + // risks capturing a torn mid-append write. Defer the whole pair + // rather than partially snapshot — a fork already needs a human + // decision, so waiting for the session to go quiet costs nothing + // and this pair gets re-evaluated (still un-classified) next run. + if (isLive(correct, liveMs, now)) { + findings.push({ sessionId, homeFolder: P, kind: 'deferred-live', paths: [file, correct] }); + break; + } + // Case C — NEVER automated (spec §6.0). Snapshot both, change nothing. + // Fix (fork hold): a fork already held from a prior run (state's + // surfacedForks) doesn't need re-snapshotting every launch — the + // FIRST run's quarantine copies are the ones that matter, and the + // fork is already frozen out of both mirror directions (see + // heldForkIds' WHY). Still push the finding + ATTENTION log so it + // keeps surfacing until a human resolves it. + if (opts.heldForks?.has(sessionId)) { + q.log(`SKIP-SNAPSHOT fork ${sessionId}: snapshots already held from a prior run`); + } else { + q.snapshot(file, `6.1 FORK ${sessionId} ($HOME copy)`); + q.snapshot(correct, `6.1 FORK ${sessionId} (project copy)`); + } + q.log(`ATTENTION fork ${sessionId}: ${file} vs ${correct} — both left on disk; user decision required`); + findings.push({ sessionId, homeFolder: P, kind: 'fork-surfaced', paths: [file, correct] }); + break; + } + } + return findings; +} + +/** §6.2 — per-session repair of conversation RECORDS and the sync-space + * transcript copies the old bug filed under wrong buckets. Built BY SESSION + * (never bucket-scoped): spec §4 #6 notes 'destin' is a legitimate bucket + * that can ALSO hold a couple of mis-filed sessions, and bucket-scoped + * repair would either miss those or destroy every correct record sharing + * the bucket. Record repair is metadata-only (projectName/originalPath/ + * transcriptRef, no lastActive) — Task 12's pinning test proved the + * reconcile sweep is record-keyed, so a metadata-only upsert is sufficient + * to stop the sweep re-filing the session under the wrong bucket again. */ +export async function repairRecordsAndSpace( + opts: RepairOpts & { store: import('./conversation-store').ConversationStore; spaceRoot: string }, +): Promise { + const { projectsDir, homeDir, knownFolders, quarantine: q, store, spaceRoot } = opts; + const liveMs = opts.liveMs ?? LIVE_MTIME_MS; + const now = opts.now ?? Date.now; + // Adaptation (disclosed): thread the platform test seam through firstCwd/ + // isForeignCwd here too, same as repairHomeForks (§6.1) — otherwise a + // POSIX fixture's cwd only reads correctly on a POSIX CI runner. + const platform = opts.platform ?? process.platform; + const findings: RepairFinding[] = []; + const lane = path.join(spaceRoot, 'claude', 'transcripts'); + // Review fix (IMPORTANT 2): protection for the $HOME bucket must be + // STRUCTURAL, not incidental. Before this fix, knownBasenames only held + // known-folder basenames — if the actual $HOME bucket (spec: 'destin' is + // real $HOME data) happened to go empty during a run, nothing stopped it + // from being retired right alongside a truncation fragment. Add + // basename(homeDir) explicitly so the protection holds regardless of + // whether $HOME also happens to collide with a known folder's name. + const knownBasenames = new Set([...knownFolders.map(p => path.basename(p)), path.basename(homeDir)]); + + // Build the repair set BY SESSION (spec §4 #6: 'destin' is a legitimate + // bucket with two mis-filed sessions — bucket-scoped repair would either + // miss them or destroy ~58 correct records). + const repairSet = new Map(); // sessionId -> project folder P + // (a) every top-level transcript in a known folder's correct CC dir — + // GATED to R2-owned sessions only (final review, CRITICAL 1). Spec §6.2's + // own set definition is "R2 home is a known folder P" — it is NOT "every + // file sitting in P's directory". Without this gate, a foreign-cwd + // transcript materialized here by sync (majority of some dirs, §1) got + // added unconditionally, and its record's originalPath — which describes + // the ORIGIN device, store-core.ts:26 — was silently overwritten with + // THIS device's path and pushed to the store (and from there synced to + // peers), with no log line anywhere. Mirrors repairHomeForks' R2 gate above. + for (const P of knownFolders) { + const correctDir = path.join(projectsDir, ccProjectSlug(P)); + for (const f of topLevelJsonl(correctDir)) { + const cwd = firstCwd(f, platform); + if (!cwd || isForeignCwd(cwd, platform) || !sameDir(cwd, P)) continue; + repairSet.set(path.basename(f, '.jsonl'), P); + } + } + // (b) every space transcript whose R2 home is a known folder filed under the + // wrong bucket (covers space-only sessions like a943d85d) + let buckets: string[] = []; + try { buckets = fs.readdirSync(lane); } catch { /* no space yet */ } + for (const bucket of buckets) { + for (const f of topLevelJsonl(path.join(lane, bucket))) { + const cwd = firstCwd(f, platform); + if (!cwd || isForeignCwd(cwd, platform)) continue; + const P = knownFolders.find(p => sameDir(p, cwd)); + if (P && path.basename(P) !== bucket) repairSet.set(path.basename(f, '.jsonl'), P); + } + } + + const emptiedBuckets = new Set(); + for (const [sessionId, P] of repairSet) { + const bucketName = path.basename(P); + const target = path.join(lane, bucketName, `${sessionId}.jsonl`); + const rec = await store.get('claude', sessionId); + const recordOk = rec && rec.projectName === bucketName && rec.originalPath === P; + // Space copies across ALL buckets for this session: + const copies = buckets + .map(b => path.join(lane, b, `${sessionId}.jsonl`)) + .filter(p => fs.existsSync(p)); + // Fix (final review, CRITICAL 2): convergence must be a true fixed point, + // not just "zero copies anywhere". The old skip only fired when there + // were literally no space copies at all, so a HEALTHY session — record + // already correct, its single copy already sitting in the right bucket — + // fell through to the live-guard/keeper-selection logic below every + // single launch: re-evaluated, re-found as a 'record-repaired' finding + // forever, and a recently-mirrored copy could trip the live guard into a + // false 'deferred-live' → false ATTENTION surfacing after MAX_DEFERRALS. + // copiesElsewhere excludes copies already filed under the correct + // bucket, so "record correct AND nothing left to relocate" is + // recognized and skipped BEFORE any live-guard check runs against a + // copy that was never going anywhere. + const copiesElsewhere = copies.filter(c => !sameDir(path.dirname(c), path.dirname(target))); + if (recordOk && copiesElsewhere.length === 0) continue; // converged — zero findings + + let moved = false; // did an actual file rename happen for this session? + if (copies.length > 0) { + if (copies.some(c => isLive(c, liveMs, now))) { + findings.push({ sessionId, homeFolder: P, kind: 'deferred-live', paths: copies }); + continue; + } + // Keeper = the copy every other copy is a subset of; tie → most uuids. + const sized = copies.map(c => ({ c, u: uuidSet(c) })) + .sort((a, b) => b.u.size - a.u.size); + const keeper = sized[0]; + + // Fork gate (spec §6.0 Case C — review fix, CRITICAL). The uuid-count + // sort above only PICKS a candidate keeper; it does not prove every + // other copy is actually contained in it. classifyPair already knows + // how to tell "clean subset" from "diverges" (including same-uuid + // content divergence), so run it before touching disk: any copy that + // ISN'T identical-to or a uuid-subset-of the keeper holds content the + // keeper lacks, and quarantining it would silently discard that + // content — exactly the forbidden "auto-resolve a fork" hazard §6.1 + // already guards against for the $HOME-fork case. An equal-uuid-COUNT + // tie between two non-identical copies is NECESSARILY a fork by this + // same check: equal count + not byte-identical means neither can be a + // uuid subset of the other, so classifyPair can only return 'fork' for + // that pair — the containment check subsumes the count tie-break, it + // is not a separate rule. + const forkPairs = sized.slice(1).filter(other => { + const verdict = classifyPair(other.c, keeper.c); + return verdict !== 'identical' && verdict !== 'wrong-is-subset'; + }); + if (forkPairs.length > 0) { + // Case C — NEVER automated. Snapshot every copy, change nothing, + // surface it — mirrors §6.1's fork discipline exactly. Fix (fork + // hold): skip re-snapshotting a fork already held from a prior run — + // see the identical WHY on the §6.1 branch above. + if (opts.heldForks?.has(sessionId)) { + q.log(`SKIP-SNAPSHOT space fork ${sessionId}: snapshots already held from a prior run`); + } else { + for (const { c } of sized) q.snapshot(c, `6.2 FORK ${sessionId} (space copy)`); + } + q.log(`ATTENTION space fork ${sessionId}: ${copies.join(', ')} — not a clean containment chain; all copies left on disk, user decision required`); + findings.push({ sessionId, homeFolder: P, kind: 'fork-surfaced', paths: copies }); + continue; // no moves, no record upsert — the whole session is skipped + } + + for (const other of sized.slice(1)) { + if (q.move(other.c, `6.2 ${sessionId}: non-keeper space copy`)) { + const b = path.basename(path.dirname(other.c)); + if (topLevelJsonl(path.join(lane, b)).length === 0) emptiedBuckets.add(b); + } + } + if (!sameDir(keeper.c, target)) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + // Adaptation (disclosed): §6.1's promotion renames are fail-closed + // (review fix, commit a10bccb4) — this rename can move a session + // that belongs to a live one just as easily, so it gets the same + // discipline: log + finding + continue, never throw mid-run. On + // failure the keeper stays exactly where it was (no record repair + // below, so nothing points at a target that doesn't exist yet). + try { + fs.renameSync(keeper.c, target); + } catch (e) { + q.log(`ERROR ${sessionId}: move-bucket rename failed — space copy still at ${keeper.c}: ${String(e)}`); + findings.push({ sessionId, homeFolder: P, kind: 'rename-failed', paths: [keeper.c] }); + continue; + } + q.log(`MOVE-BUCKET ${keeper.c} -> ${target}`); + moved = true; + const b = path.basename(path.dirname(keeper.c)); + if (topLevelJsonl(path.join(lane, b)).length === 0) emptiedBuckets.add(b); + } + } + // Record repair — THE step that stops the $HOME fork recurring (spec §4): + // metadata-only upsert (no lastActive); projectName/originalPath/ + // transcriptRef are local truth and always land (conversation-store.ts). + // Fix (final review, CRITICAL 1+2 item 3): every record repair is a + // materially consequential mutation — spec §6.0 requires every decision + // logged, and this is the write that stops the $HOME fork recurring, so + // it must be reconstructable from the decisions log alone. + const targetTranscriptRef = `claude/transcripts/${bucketName}/${sessionId}.jsonl`; + // Fix (2026-08-15, real-data run 4): only treat this as a REPAIR if a + // field is actually changing. Before this, a session that reached here + // purely because it had a stray space copy to quarantine (record already + // correct) still got a no-op upsert + RECORD-REPAIR log line + a + // 'record-repaired' finding — 6 such no-op lines on the real device run. + // The MOVE line above already documents the copy cleanup; an identical + // old->new upsert is noise that inflates the INFO summary's + // record-repaired count and rewrites the record file for nothing. + const recordChanged = !rec || rec.projectName !== bucketName || rec.originalPath !== P + || rec.transcriptRef !== targetTranscriptRef; + if (recordChanged) { + // Review fix (IMPORTANT 1): guard the upsert — it can throw on a lock + // timeout (conversation-store.ts's mutateRecord/mutateFileUnderLock). + // Unguarded, that throw rejected the whole runSlugRepair call before + // writeState() ran, silently losing every finding/hold already + // computed this run. Fail this ONE session's record repair instead: + // log it, surface a finding so it isn't silently dropped, and move on + // — the space-copy cleanup above (if any) already landed regardless. + try { + await store.upsert({ + id: sessionId, provider: 'claude', + projectName: bucketName, originalPath: P, + transcriptRef: targetTranscriptRef, + }); + } catch (e) { + q.log(`ERROR RECORD-REPAIR ${sessionId}: upsert failed: ${String(e)}`); + // Fix (review, Minor 1): the rename (if any) succeeded — only the + // record write threw. 'rename-failed' would tell a consumer the + // opposite of what happened; 'record-repair-failed' says precisely + // which half broke. + findings.push({ sessionId, homeFolder: P, kind: 'record-repair-failed', paths: moved ? [target] : [] }); + continue; + } + q.log(`RECORD-REPAIR ${sessionId}: projectName '${rec?.projectName ?? ''}' -> '${bucketName}', originalPath '${rec?.originalPath ?? ''}' -> '${P}', transcriptRef '${rec?.transcriptRef ?? ''}' -> '${targetTranscriptRef}'`); + } + // Review fix (IMPORTANT 1): a session with no space copies to move (or + // whose keeper was already correctly bucketed) never touches a file — + // 'moved' would misdescribe it as a physical relocation that never + // happened. 'record-repaired' names the no-file-move path precisely; + // Task 17's consumer can tell the two apart instead of trusting a path + // that may never have existed. Skip the finding entirely when neither a + // file moved nor a field changed — the copy cleanup (if any) already has + // its own MOVE log line, and there's nothing left to call a "repair". + if (moved || recordChanged) { + findings.push({ sessionId, homeFolder: P, kind: moved ? 'moved' : 'record-repaired', paths: [target] }); + } + } + + // Retire ONLY emptied truncation-fragment buckets — never a legitimate one + // ('destin' is real $HOME data; a known folder's basename is real too). + for (const b of emptiedBuckets) { + if (knownBasenames.has(b)) continue; + const dir = path.join(lane, b); + try { + if (fs.readdirSync(dir).length === 0) q.move(dir, `6.2 emptied truncation bucket`); + } catch { /* already gone */ } + } + return findings; +} + +/** §6.3 — retire the ORPHAN-rule (`nativeStoreSlug`) project dirs the old bug + * left behind, now that a project P has both an orphan dir and the CC-rule + * (`ccProjectSlug`) correct dir. Only pairs where the two rules actually + * DISAGREE and BOTH dirs exist are in scope — an orphan dir with no correct + * sibling is not this function's problem (nothing to reconcile against), and + * a P where the two rules agree can't have a separate orphan dir at all. + * Per-session classification against the CC-dir copy reuses the exact same + * case discipline as §6.1 (identical/subset quarantine, superset promotion, + * fork snapshot-and-surface, live defer) — only the source dir differs. */ +export function repairOrphanDirs(opts: RepairOpts): RepairFinding[] { + const { projectsDir, knownFolders, quarantine: q } = opts; + const liveMs = opts.liveMs ?? LIVE_MTIME_MS; + const now = opts.now ?? Date.now; + const findings: RepairFinding[] = []; + for (const P of knownFolders) { + const orphanSlug = nativeStoreSlug(P); + const ccSlug = ccProjectSlug(P); + if (orphanSlug === ccSlug) continue; // rules agree — no orphan possible + const orphanDir = path.join(projectsDir, orphanSlug); + const correctDir = path.join(projectsDir, ccSlug); + if (!fs.existsSync(orphanDir) || !fs.existsSync(correctDir)) continue; + for (const file of topLevelJsonl(orphanDir)) { + const sessionId = path.basename(file, '.jsonl'); + if (isLive(file, liveMs, now)) { findings.push({ sessionId, homeFolder: P, kind: 'deferred-live', paths: [file] }); continue; } + const correct = path.join(correctDir, path.basename(file)); + if (!fs.existsSync(correct)) { + // Adaptation (disclosed): §6.1's promotion rename is fail-closed + // (review fix) — this is the same shape (rename an orphan copy into + // the spot where nothing currently lives), so it gets the same + // discipline. On failure `file` never left, so nothing is lost. + try { + fs.renameSync(file, correct); // session exists ONLY in the orphan — preserve it + } catch (e) { + q.log(`ERROR ${sessionId}: move-to-correct rename failed — orphan copy still at ${file}: ${String(e)}`); + findings.push({ sessionId, homeFolder: P, kind: 'rename-failed', paths: [file] }); + continue; + } + q.log(`MOVE-TO-CORRECT ${file} -> ${correct} (orphan-only)`); + findings.push({ sessionId, homeFolder: P, kind: 'moved', paths: [correct] }); + continue; + } + switch (classifyPair(file, correct)) { + case 'identical': case 'wrong-is-subset': + if (q.move(file, `6.3 ${sessionId}: orphan copy ⊆ correct`)) findings.push({ sessionId, homeFolder: P, kind: 'quarantined', paths: [file] }); + break; + case 'wrong-is-superset': + // Adaptation (disclosed): mirrors §6.1's CRITICAL review fix — + // `correct` is the CC-tracked file here too; quarantining it while + // CC actively appends would steal the inode out from under an open + // fd. Defer the whole pair instead of racing it. + if (isLive(correct, liveMs, now)) { + findings.push({ sessionId, homeFolder: P, kind: 'deferred-live', paths: [file, correct] }); + break; + } + if (q.move(correct, `6.3 ${sessionId}: correct superseded by orphan copy`)) { + try { + fs.renameSync(file, correct); + q.log(`MOVE-TO-CORRECT ${file} -> ${correct} (superset)`); + findings.push({ sessionId, homeFolder: P, kind: 'replaced-with-superset', paths: [correct] }); + } catch (e) { + const quarantinedAt = path.join(q.dir, path.relative(q.homeRoot, correct)); + q.log(`ERROR ${sessionId}: promotion rename failed after quarantine — superseded copy at ${quarantinedAt}, orphan copy still at ${file}: ${String(e)}`); + findings.push({ sessionId, homeFolder: P, kind: 'rename-failed', paths: [file, quarantinedAt] }); + } + } + break; + case 'fork': + // Same live-guard as the superset branch above — snapshotting + // `correct` mid-append risks capturing a torn write. + if (isLive(correct, liveMs, now)) { + findings.push({ sessionId, homeFolder: P, kind: 'deferred-live', paths: [file, correct] }); + break; + } + // Fix (fork hold): skip re-snapshotting a fork already held from a + // prior run — see the identical WHY on the §6.1 branch above. + if (opts.heldForks?.has(sessionId)) { + q.log(`SKIP-SNAPSHOT fork ${sessionId}: snapshots already held from a prior run`); + } else { + q.snapshot(file, `6.3 FORK ${sessionId} (orphan)`); q.snapshot(correct, `6.3 FORK ${sessionId} (correct)`); + } + q.log(`ATTENTION fork ${sessionId}: ${file} vs ${correct}`); + findings.push({ sessionId, homeFolder: P, kind: 'fork-surfaced', paths: [file, correct] }); + break; + } + } + try { + if (fs.readdirSync(orphanDir).length === 0) q.move(orphanDir, '6.3 emptied orphan dir'); + } catch { /* gone */ } + } + return findings; +} + +const MAX_DEFERRALS = 3; + +/** The single startup entry point (spec §6.0/§6.5) — safe to call every + * launch. Runs 6.1 -> 6.2 -> 6.3 in that STRICT order (6.2's record repair + * depends on 6.1 having settled $HOME copies first; 6.3's orphan retirement + * must run LAST because 6.2 can relocate a space copy that originated FROM + * an orphan-dir file — retiring the orphan before 6.2 runs would remove the + * only surviving source for that relocation). Bounds live-session deferral + * so a session that never goes quiet doesn't get silently retried forever — + * after MAX_DEFERRALS consecutive live findings it's surfaced via a WARN log + * and a quarantine ATTENTION line instead. A surfaced fork gets a one-time + * store note, and ONLY when the record's existing note is empty — repair + * must never clobber a user's own note. */ +// Fix: does NOT pause/resume the reconcile+materialize sweeps itself — the +// CALLER owns that (main.ts, around its startConversationStore({ pauseSweeps: +// true }).then(runSlugRepair).finally(resumeSweeps) chain). A single owner +// avoids double-pause/double-resume bookkeeping; see pauseSweeps' WHY in +// conversations/service.ts for what races if the caller skips this. +export async function runSlugRepair(overrides?: Partial & { + store?: import('./conversation-store').ConversationStore | null; + spaceRoot?: string; + stateFile?: string; + // Test-only seam (review fix, Minor 2): lets a test substitute one stage's + // implementation (e.g. force it to throw) to exercise the per-stage + // try/catch below. vi.spyOn on this module's exports would NOT work here — + // runSlugRepair calls repairHomeForks/repairRecordsAndSpace/repairOrphanDirs + // as local same-module bindings, which a compiled module calls directly + // rather than through its exports object, so spying the export never + // intercepts these call sites. Never set in production. + stages?: { + repairHomeForks?: typeof repairHomeForks; + repairRecordsAndSpace?: typeof repairRecordsAndSpace; + repairOrphanDirs?: typeof repairOrphanDirs; + }; +}): Promise { + const homeDir = overrides?.homeDir ?? os.homedir(); + const store = overrides?.store !== undefined ? overrides.store : getConversationStore(); + if (!store) return; // store not up — next launch retries + const spaceRoot = overrides?.spaceRoot ?? store.root(); + const projectsDir = overrides?.projectsDir ?? path.join(homeDir, '.claude', 'projects'); + let knownFolders = overrides?.knownFolders; + if (!knownFolders) { + // Fix: this MUST match runReconcile's knownFolders assembly exactly (service.ts + // runReconcile) — managed projects first, then saved folders, each source + // individually try-guarded so one failing source doesn't blank the other. Before + // this fix the repair only read saved folders, so a managed-only project (not in + // ~/.claude/youcoded-folders.json) was invisible to the repair even though the + // reconciler buckets by it — the repair silently did nothing for that project's + // mis-filed data. Found on the first real-data run, 2026-08-15 (PAF 574 project). + knownFolders = []; + try { knownFolders.push(...(getManagedRoots()?.listProjects() ?? []).map(p => p.path)); } + catch { /* managed roots unreadable — saved folders still cover most cases */ } + try { knownFolders.push(...readFolders().map(f => f.path)); } + catch { /* saved folders unreadable — managed projects still cover most cases */ } + } + if (knownFolders.length === 0) return; + const quarantine = overrides?.quarantine ?? new Quarantine(homeDir); + // Fix (fork hold): load the runner's state file ONCE, up front — both the + // deferral bookkeeping below AND the heldForks set threaded into opts (so + // the fork branches can skip re-snapshotting an already-held pair) read + // from this same snapshot. See heldForkIds' WHY in slug-repair-state.ts. + const stateFile = overrides?.stateFile ?? defaultStateFile(homeDir); + const state = readState(stateFile); + const heldForks = new Set(state.surfacedForks.map(f => f.id)); + const opts: RepairOpts = { projectsDir, homeDir, knownFolders, quarantine, heldForks, + liveMs: overrides?.liveMs, now: overrides?.now }; + + const stageFns = { + repairHomeForks: overrides?.stages?.repairHomeForks ?? repairHomeForks, + repairRecordsAndSpace: overrides?.stages?.repairRecordsAndSpace ?? repairRecordsAndSpace, + repairOrphanDirs: overrides?.stages?.repairOrphanDirs ?? repairOrphanDirs, + }; + + // ORDER IS LOAD-BEARING (spec §6.0): space repair (6.2) BEFORE orphan + // retirement (6.3) — the truncation bucket was populated FROM the orphan. + // Fix (review, Minor 2): each stage runs in its OWN try/catch and a throw + // never aborts the run — it's logged and the run continues to the NEXT + // stage and on to finalization (state write, notes, summary log). WHY: a + // filesystem throw partway through a later stage (e.g. a file vanishing + // between an exists-check and a read in classifyPair — TOCTOU, always + // possible against a live tree) must never discard the findings/holds an + // EARLIER stage already gathered — finalization below is what persists + // them (writeState), and an unguarded throw here would reject the whole + // async function before that write ever ran, silently losing them. The + // stage ORDER itself is untouched by this: a failed stage is skipped, not + // reordered or retried within the same run — 6.1/6.2/6.3 still only ever + // run in that sequence. + const all: RepairFinding[] = []; + try { + all.push(...stageFns.repairHomeForks(opts)); // 6.1 + } catch (e) { + log('ERROR', 'SlugRepair', 'stage failed', { stage: '6.1 repairHomeForks', error: String(e) }); + quarantine.log(`ERROR stage 6.1 repairHomeForks failed: ${String(e)}`); + } + try { + all.push(...await stageFns.repairRecordsAndSpace({ ...opts, store, spaceRoot })); // 6.2 + } catch (e) { + log('ERROR', 'SlugRepair', 'stage failed', { stage: '6.2 repairRecordsAndSpace', error: String(e) }); + quarantine.log(`ERROR stage 6.2 repairRecordsAndSpace failed: ${String(e)}`); + } + // WHY (final review, IMPORTANT 5): 6.3 runs AFTER 6.2 in this same pass, so + // a session 6.3 promotes into the correct CC dir THIS run was already past + // 6.2's scan and does not get its record repaired until the NEXT launch's + // 6.2 pass picks up the newly-present file. One-launch lag, known and + // self-correcting (the runner runs every startup) — do not read a + // surviving wrong record right after a supervised run as a failure; check + // again after one more launch. + try { + all.push(...stageFns.repairOrphanDirs(opts)); // 6.3 + } catch (e) { + log('ERROR', 'SlugRepair', 'stage failed', { stage: '6.3 repairOrphanDirs', error: String(e) }); + quarantine.log(`ERROR stage 6.3 repairOrphanDirs failed: ${String(e)}`); + } + + // Bounded deferral (spec §6.5): live sessions retry next launch, at most + // MAX_DEFERRALS times, then surface instead of looping silently. + // Review fix: the deferral contract is per-RUN ("3 runs in a row"), not + // per-FINDING. One session can produce a 'deferred-live' finding from more + // than one step in the SAME run — e.g. live in both the $HOME slug dir + // (§6.1's scan) and an orphan-dir pair (§6.3's scan) — so dedupe to a Set + // BEFORE incrementing, or a single launch could silently burn through + // multiple deferrals at once and surface a session in fewer real runs than + // the contract states. + const deferredThisRun = new Set(all.filter(f => f.kind === 'deferred-live').map(f => f.sessionId)); + const forkSurfacedThisRun = new Set(all.filter(f => f.kind === 'fork-surfaced').map(f => f.sessionId)); + // Fix (review, IMPORTANT 2 — auto-release on absence of evidence): keyed by + // id -> the paths that finding recorded, so release decisions below can + // check the disk, not just "did this run mention the id". Seeded from the + // pre-run state so an id nothing touches this run keeps its LAST recorded + // paths (needed for the "one recorded path no longer exists" release + // check further down). + const surfacedMap = new Map(state.surfacedForks.map(f => [f.id, f.paths])); + for (const f of all) { + if (f.kind !== 'deferred-live') delete state.deferred[f.sessionId]; + // Paths are refreshed to whatever THIS run's finding says, every time a + // fork surfaces — including a re-surface of an already-held id, so a + // stale hold never carries paths from before a promotion/rename moved + // one of the copies. + if (f.kind === 'fork-surfaced') surfacedMap.set(f.sessionId, f.paths); + } + // Fix (review, IMPORTANT 2): a hold releases ONLY on positive evidence the + // pair is no longer a fork — never on this run simply having nothing to say + // about the id. Before this fix, silence alone (e.g. the folder that owns + // the fork dropped out of knownFolders, or readFolders() threw and returned + // []) released the hold, and materializeSweep — independent of + // knownFolders, it resolves via the record's originalPath — clobbered the + // smaller fork copy within seconds of the sweeps resuming. Only ids ALREADY + // held coming into this run (`heldForks`, the pre-run snapshot) are release + // candidates; a fork surfaced for the FIRST time this run was just added to + // `surfacedMap` above and is never a candidate. + for (const id of heldForks) { + if (forkSurfacedThisRun.has(id) || deferredThisRun.has(id)) continue; // still a fork, or paused — stays held + // (a) positive reclassification: THIS run produced some other, + // non-fork/non-deferred finding for the id — the pair converged into a + // clean subset/superset relation (or got its record repaired), which + // only happens once classifyPair no longer calls it a fork. + const reclassified = all.some(f => f.sessionId === id && f.kind !== 'fork-surfaced' && f.kind !== 'deferred-live'); + // (b) the user resolved it by hand: at least one of the copies this hold + // was protecting is gone from disk. + const recordedPaths = surfacedMap.get(id) ?? []; + const copyMissing = recordedPaths.some(p => !fs.existsSync(p)); + if (reclassified || copyMissing) surfacedMap.delete(id); + // else: silence with every recorded copy still present on disk — the + // scan simply never reached this pair this run. Stay held. + } + state.surfacedForks = [...surfacedMap].map(([id, paths]) => ({ id, paths })); + for (const sessionId of deferredThisRun) { + const n = (state.deferred[sessionId] ?? 0) + 1; + state.deferred[sessionId] = n; + if (n >= MAX_DEFERRALS) { + log('WARN', 'SlugRepair', 'session still live after repeated deferrals — needs manual quiescence', { sessionId, deferrals: n }); + quarantine.log(`ATTENTION deferred ${sessionId} ${n}x — repair it manually while the app is closed`); + } + } + // Fix (review, IMPORTANT 1): persist the hold/deferral bookkeeping computed + // above BEFORE the store calls below, which can throw (lock timeout — + // conversation-store.ts's mutateRecord). Before this fix, an unguarded + // store.setNote() rejecting this whole function meant writeState() (further + // down) never ran — forks just surfaced/snapshotted this run were never + // recorded as held, and main.ts's .finally(resumeSweeps) unpaused the + // mirror sweeps over an unrecorded hold: the exact clobber this branch + // exists to prevent. Written again (idempotent) at the end so a future edit + // that adds more post-processing state here doesn't have to remember to + // move this call again. + try { writeState(state, stateFile); } + catch (e) { log('WARN', 'SlugRepair', 'state write failed', { error: String(e) }); } + // Best-effort store notification for freshly/still-surfaced forks. Wrapped + // (review fix, IMPORTANT 1) — setNote is documented one-time best-effort; + // a lock-timeout rejection here must never cost the hold state already + // written above. + for (const f of all) { + if (f.kind !== 'fork-surfaced') continue; + log('WARN', 'SlugRepair', 'true fork left on disk — user decision required', { sessionId: f.sessionId, paths: f.paths }); + try { + const rec = await store.get('claude', f.sessionId); + if (rec && !rec.note) { + await store.setNote('claude', f.sessionId, + `Repair notice: this conversation has two diverged copies on disk (see ~/.youcoded/repair-quarantine). Both were preserved.`); + } + } catch (e) { + log('WARN', 'SlugRepair', 'fork note not written', { sessionId: f.sessionId, error: String(e) }); + } + } + try { writeState(state, stateFile); } + catch (e) { log('WARN', 'SlugRepair', 'state write failed', { error: String(e) }); } + if (all.length) log('INFO', 'SlugRepair', 'repair pass complete', { findings: all.map(f => ({ id: f.sessionId, kind: f.kind })) }); +} diff --git a/desktop/src/main/harness/native-session-host.ts b/desktop/src/main/harness/native-session-host.ts index f6db64f8c..f2da86ae9 100644 --- a/desktop/src/main/harness/native-session-host.ts +++ b/desktop/src/main/harness/native-session-host.ts @@ -46,8 +46,10 @@ import { buildTriggerIndex } from './injection/path-triggers'; import { log } from '../logger'; // Same import PermissionStore uses, for the same reason: the project slug MUST // come from ONE function everywhere, or the host and the store would disagree -// about which live sessions a stored entry belongs to. -import { cwdToProjectSlug } from '../transcript-watcher'; +// about which live sessions a stored entry belongs to. nativeStoreSlug (NOT +// ccProjectSlug): this is app-private permissions.json keying, not a CC +// mirror — see slug-encoding.ts. +import { nativeStoreSlug } from '../slug-encoding'; import type { McpLease } from './mcp/mcp-manager'; export interface CreateNativeSessionOpts { @@ -64,7 +66,7 @@ export interface RememberedRuleStore { rulesFor(cwd: string): Promise; remember(cwd: string, rule: PermissionRule): Promise; // Removal keys by project SLUG, not cwd: the slug is what is actually on disk, - // and cwdToProjectSlug is lossy (see revokeRule), so an entry written before + // and nativeStoreSlug is lossy (see revokeRule), so an entry written before // the management UI existed has no recoverable cwd to pass. Both return // whether anything actually matched, so the caller can tell the user their // on-screen list was stale instead of claiming a success that never happened. @@ -636,7 +638,7 @@ export class NativeSessionHost extends EventEmitter { * revokeRule / revokeProject are disk PLUS live memory. IPC handlers must call * these, never the store's — "fixing the inconsistency" reintroduces the bug. * - * Matching is by SLUG, never by path equality: cwdToProjectSlug collapses ':', + * Matching is by SLUG, never by path equality: nativeStoreSlug collapses ':', * '\', '/' AND spaces all to '-', so two differently-spelled cwds ('/home/d/my * project' and '/home/d/my-project') genuinely share one entry on disk — and * must therefore both be cleared in memory too. @@ -650,7 +652,7 @@ export class NativeSessionHost extends EventEmitter { async revokeRule(slug: string, rule: PermissionRule): Promise { const hit = await this.permissionStore.remove(slug, rule); for (const [sessionId, entry] of this.live) { - if (cwdToProjectSlug(entry.cwd) !== slug) continue; + if (nativeStoreSlug(entry.cwd) !== slug) continue; const mem = this.rememberedFor.get(sessionId); if (!mem) continue; this.rememberedFor.set(sessionId, mem.filter((r) => !sameRule(r, rule))); @@ -666,7 +668,7 @@ export class NativeSessionHost extends EventEmitter { for (const [sessionId, entry] of this.live) { // delete, not set([]): an absent entry and an empty one read identically in // buildDecide (`?? []`), and deleting keeps the map from accumulating empties. - if (cwdToProjectSlug(entry.cwd) === slug) this.rememberedFor.delete(sessionId); + if (nativeStoreSlug(entry.cwd) === slug) this.rememberedFor.delete(sessionId); } return hit; } diff --git a/desktop/src/main/harness/permission-store.ts b/desktop/src/main/harness/permission-store.ts index a437b064c..1b5f153d6 100644 --- a/desktop/src/main/harness/permission-store.ts +++ b/desktop/src/main/harness/permission-store.ts @@ -6,10 +6,11 @@ // ~/.youcoded/ JSON (native-home invariant). Reads use NativeHome.readJson // (synchronous; null for a missing/corrupt file). // -// WHY imported from transcript-watcher: the slug MUST match CC's project-dir -// encoding exactly (one function, one convention — see cwdToProjectSlug docs), -// same as session-store.ts does. -import { cwdToProjectSlug } from '../transcript-watcher'; +// WHY nativeStoreSlug (NOT the CC mirror): this file keys +// ~/.youcoded/permissions.json, which nothing external reads. Routing it to +// ccProjectSlug would silently re-key every project and DROP every remembered +// "Always allow" rule. Frozen on purpose — see slug-encoding.ts. +import { nativeStoreSlug } from '../slug-encoding'; import type { NativeHome } from '../native-home'; import { normalizeRule, sameRule } from '../../shared/permission-types'; import type { PermissionRule, StoredProject, StoredRule } from '../../shared/permission-types'; @@ -24,10 +25,11 @@ type PermEntry = { cwd?: string; rules: StoredRule[] }; type PermFile = { v: 1; projects: Record }; const EMPTY: PermFile = { v: 1, projects: {} }; -// SLUG COLLISIONS: cwdToProjectSlug collapses ':', '\\', '/', and spaces all to +// SLUG COLLISIONS: nativeStoreSlug collapses ':', '\\', '/', and spaces all to // '-', so distinct paths can theoretically map to the same slug and share rules. -// This is inherited from CC's project-dir encoding deliberately — do NOT diverge -// here; the whole point of importing cwdToProjectSlug is one convention everywhere. +// The collapse behavior is FROZEN app-private convention (slug-encoding.ts's +// nativeStoreSlug) — it no longer claims to match CC. Do not re-point at the +// CC mirror: that orphans every stored rule. // // UNBOUNDED GROWTH: rules per project accumulate without cap or eviction until // the Phase 3 permission-management UI lets the user prune them — intentional. @@ -45,12 +47,12 @@ export class PermissionStore { // would otherwise be evaluated as a glob, which is how "always allow this // exact command" turned `rm *.log` into a wildcard grant. Reading it as // exact restores the promise the user was actually shown. - return (data.projects?.[cwdToProjectSlug(cwd)]?.rules ?? []).map(normalizeRule); + return (data.projects?.[nativeStoreSlug(cwd)]?.rules ?? []).map(normalizeRule); } /** Persist one remembered decision for `cwd`'s project, deduping exact repeats. */ async remember(cwd: string, rule: PermissionRule): Promise { - const slug = cwdToProjectSlug(cwd); + const slug = nativeStoreSlug(cwd); // Read-modify-write under the file lock — never a bare write. await this.home.mutateJson(FILE, (cur) => { const data = (cur as PermFile | null) ?? EMPTY; @@ -95,7 +97,7 @@ export class PermissionStore { /** * Delete one remembered rule from a project. Keys by SLUG, not cwd — the slug - * is what's on disk, and cwdToProjectSlug is lossy so there is no cwd to pass + * is what's on disk, and nativeStoreSlug is lossy so there is no cwd to pass * for a legacy entry. Returns whether anything actually matched, so the caller * can tell the user their on-screen list was stale instead of claiming success. * diff --git a/desktop/src/main/harness/session-store.ts b/desktop/src/main/harness/session-store.ts index c912129f5..3c8ea8d0f 100644 --- a/desktop/src/main/harness/session-store.ts +++ b/desktop/src/main/harness/session-store.ts @@ -7,15 +7,13 @@ // assistant-thinking) are display-only for the same reason. import type { TranscriptEvent } from '../../shared/types'; import type { ModelBinding } from '../../shared/provider-types'; -// WHY imported from transcript-watcher: native sessions use the RAW -// cwdToProjectSlug — NOT ccProjectSlug (project-conversations.ts), which -// additionally uppercases a lowercase Windows drive letter before slugifying. -// This is a DELIBERATE divergence, not a bug: the two encodings disagree on a -// cwd like 'c:\Users\d\proj' (see session-store.test.ts's slug-divergence -// pin), and unifying them would orphan every native session file already -// written on disk under the raw slug. conversations/service.ts's -// localJsonlPath mirrors this same raw-slug convention for native paths. -import { cwdToProjectSlug } from '../transcript-watcher'; +// WHY nativeStoreSlug (NOT the CC mirror): the slug is the FROZEN app-private +// rule (slug-encoding.ts) — deliberately NOT CC's; changing it orphans +// existing native transcripts. It disagrees with ccProjectSlug on a cwd like +// 'c:\Users\d\proj' (see session-store.test.ts's slug-divergence pin). +// conversations/service.ts's localJsonlPath mirrors this same convention for +// native paths. +import { nativeStoreSlug } from '../slug-encoding'; import { NativeHome } from '../native-home'; export interface NativeSessionHeader { @@ -66,7 +64,7 @@ export class SessionStore { /** Write the session header as line 1 of a fresh session file. */ async create(header: NativeSessionHeader): Promise { - await this.home.appendSessionLine(cwdToProjectSlug(header.cwd), header.sessionId, header); + await this.home.appendSessionLine(nativeStoreSlug(header.cwd), header.sessionId, header); } /** @@ -94,7 +92,7 @@ export class SessionStore { return; } - const slug = cwdToProjectSlug(cwd); + const slug = nativeStoreSlug(cwd); const partId = event.data?.partId; if (COALESCED_TYPES.has(event.type) && partId) { @@ -175,7 +173,7 @@ export class SessionStore { /** Line 1 of the session file, validated as a v1 header for this session. */ readHeader(sessionId: string, cwd: string): NativeSessionHeader | null { - const lines = this.home.readSessionLines(cwdToProjectSlug(cwd), sessionId); + const lines = this.home.readSessionLines(nativeStoreSlug(cwd), sessionId); return this.validateHeader(lines[0], sessionId); } @@ -185,7 +183,7 @@ export class SessionStore { * must never produce duplicate reducer entries on replay. */ readEvents(sessionId: string, cwd: string): TranscriptEvent[] { - const lines = this.home.readSessionLines(cwdToProjectSlug(cwd), sessionId); + const lines = this.home.readSessionLines(nativeStoreSlug(cwd), sessionId); const seen = new Set(); const out: TranscriptEvent[] = []; for (const line of lines.slice(1)) { diff --git a/desktop/src/main/ipc-handlers.ts b/desktop/src/main/ipc-handlers.ts index 693e0e110..2b569effd 100644 --- a/desktop/src/main/ipc-handlers.ts +++ b/desktop/src/main/ipc-handlers.ts @@ -15,7 +15,8 @@ import { CommandProvider } from './command-provider'; import { IntegrationInstaller, listWithState } from './integration-installer'; import { RemoteConfig } from './remote-config'; import { RemoteServer } from './remote-server'; -import { TranscriptWatcher, cwdToProjectSlug } from './transcript-watcher'; +import { TranscriptWatcher } from './transcript-watcher'; +import { nativeStoreSlug, ccProjectSlug } from './slug-encoding'; // Native runtime (platform roadmap Phase 1 Plan A) — the first-party harness // stack: provider CRUD + key management, model catalog, and the live-session // registry that owns HarnessSessions and their persistence. @@ -120,7 +121,7 @@ import { import { initGitWatchers, watchGit, unwatchGit, dropGitSubscriber } from './git/git-watcher'; import { resolveRepoRoot, invalidateRepoRootCache } from './git/git-exec'; import { PROJECT_IPC } from './project/ipc-channels'; -import { listProjectConversations, projectConversationHistory, ccProjectSlug } from './project-conversations'; +import { listProjectConversations, projectConversationHistory } from './project-conversations'; // Conversation Store (Phase 2a): live intake of transcript activity, session // cwd, title and flag changes. Keyed by CLAUDE session id (resolved from the // desktop id via sessionIdMap below), matching the store's record id. @@ -147,12 +148,12 @@ const CLAUDE_DIR = path.join(os.homedir(), '.claude'); // Native transcript existence probe: does ~/.youcoded/sessions//.jsonl // exist for this cwd? Mirrors NativeHome.sessionPath's convention — the RAW -// cwdToProjectSlug, NOT ccProjectSlug (see session-store.ts's slug-divergence +// frozen nativeStoreSlug, NOT ccProjectSlug (see session-store.ts's slug-divergence // note). Used by the native RESUME path to validate a cwd BEFORE handing it to // nativeHost.resume, so session-manager's silent cwd→$HOME fallback can never // send a resume into the wrong (empty) directory (Task 9). function nativeTranscriptExists(cwd: string, sessionId: string): boolean { - return fs.existsSync(path.join(os.homedir(), '.youcoded', 'sessions', cwdToProjectSlug(cwd), `${sessionId}.jsonl`)); + return fs.existsSync(path.join(os.homedir(), '.youcoded', 'sessions', nativeStoreSlug(cwd), `${sessionId}.jsonl`)); } @@ -2835,10 +2836,23 @@ export function registerIpcHandlers( // Start watching the transcript file for this session const sessionInfo = sessionManager.getSession(desktopId); if (sessionInfo) { - transcriptWatcher.startWatching(desktopId, claudeId, sessionInfo.cwd); + // Spec §5.0: CC's payload carries transcript_path AND cwd (both required + // fields of its hook schema). payload.cwd is post-realpath/post-chdir — + // the exact string CC slugged — so prefer it over our sessionInfo.cwd, + // which can differ through a symlink. sessionInfo.cwd is the fallback only. + // Hardened casts (final review, MINOR fold): a raw `as string | undefined` + // trusts the hook payload's shape blindly — if CC ever sent a non-string + // for either field, the cast would silently pass it through instead of + // falling back. typeof-narrow so an unexpected shape degrades to the + // documented fallback (sessionInfo.cwd / slug derivation) instead of + // handing a non-string downstream. + const payloadCwd = typeof event.payload?.cwd === 'string' ? event.payload.cwd : undefined; + const ccCwd = payloadCwd || sessionInfo.cwd; + const ccTranscriptPath = typeof event.payload?.transcript_path === 'string' ? event.payload.transcript_path : undefined; + transcriptWatcher.startWatching(desktopId, claudeId, ccCwd, ccTranscriptPath); // Conversation Store (Phase 2a): tell the store this claude session's cwd // so its activity upserts carry projectName/originalPath (local truth). - noteSessionStarted(claudeId, sessionInfo.cwd, 'claude'); + noteSessionStarted(claudeId, ccCwd, 'claude'); // 2b Task 8: this device now owns the session — take the lease. // Fire-and-forget: a denied (ok:false) result would only mean another // device holds it, but the sanctioned resume path already ran takeover diff --git a/desktop/src/main/main.ts b/desktop/src/main/main.ts index 13ea04420..7b1af1ebd 100644 --- a/desktop/src/main/main.ts +++ b/desktop/src/main/main.ts @@ -53,7 +53,8 @@ import { upsertSelf } from './sync-spaces/device-registry'; // Conversation Store (Phase 2a): records + transcript sync ride the personal // space. Imported statically like the sync-spaces stop so the non-async quit // handler can call stopConversationStore() directly. -import { startConversationStore, stopConversationStore, materializeOne, HANDOFF_SYNC_TIMEOUT_MS } from './conversations/service'; +import { startConversationStore, stopConversationStore, materializeOne, resumeSweeps, HANDOFF_SYNC_TIMEOUT_MS } from './conversations/service'; +import { runSlugRepair } from './conversations/slug-repair'; import { startChatsearchIndex, stopChatsearchIndex } from './chatsearch-index/index-service'; // One-time cleanup of the legacy sync-service's slug-symlink aggregation (Plan 2c). import { sweepProjectSymlinks } from './conversations/symlink-sweep'; @@ -1946,7 +1947,17 @@ void app.whenReady().then(async () => { // populated (its synchronous prologue creates the roots before its first // await, so the roots exist by the time this line runs). startConversationStore // resolves fast — the first-run reconcile inside is detached (may mirror GBs). - startConversationStore().catch(e => log('ERROR', 'Main', 'ConversationStore start failed', { error: String(e) })); + // Fix: pauseSweeps quiesces the startup reconcile/materialize kicks (and any + // trigger that fires while the repair runs) so the one-shot slug repair + // below never races them — a race here re-quarantines and re-resurrects + // records on every launch (found on the real-data run, see resumeSweeps' + // caller-side note and pauseSweeps' WHY in conversations/service.ts). + // .finally ALWAYS resumes, even if the repair throws, so a bad repair can't + // leave sync mirroring silently disabled forever. + startConversationStore({ pauseSweeps: true }) + .then(() => runSlugRepair()) // idempotent; runs with the sweeps quiesced (spec §6) + .catch(e => log('ERROR', 'Main', 'ConversationStore start / slug repair failed', { error: String(e) })) + .finally(() => resumeSweeps()); // One-time symlink sweep (Plan 2c): the legacy SyncService.aggregateConversations()/ // rewriteProjectSlugs() (deleted this release) left ~hundreds of symlinks/junctions diff --git a/desktop/src/main/project-context.ts b/desktop/src/main/project-context.ts index 71edc28f1..2ae31fb1c 100644 --- a/desktop/src/main/project-context.ts +++ b/desktop/src/main/project-context.ts @@ -2,23 +2,13 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { discoverContext, RuleEntry } from './project/context-discovery'; -import { cwdToProjectSlug } from './transcript-watcher'; +import { ccProjectSlug } from './slug-encoding'; import { RECOGNIZED_INSTRUCTION_FILES, ContextGroup, ContextFile } from '../shared/project-context-types'; import { canonicalize } from '../shared/artifacts/canonicalize'; const HOME = os.homedir(); const CLAUDE_DIR = path.join(HOME, '.claude'); -// CC encodes its project dirs with an UPPERCASE drive letter (C--Users-…), but -// YouCoded's canonical project paths can carry a LOWERCASE drive (c:/Users/…). -// Uppercase the drive before slugifying so the memory dir (~/.claude/projects/ -// /memory) resolves; without this the Memory group is silently empty on -// Windows. Windows paths are case-insensitive, so only the drive is normalized. -function ccProjectSlug(projectPath: string): string { - const driveNormalized = projectPath.replace(/^([a-z]):/, (_m, d) => `${d.toUpperCase()}:`); - return cwdToProjectSlug(driveNormalized); -} - async function exists(p: string): Promise { try { await fs.promises.access(p); return true; } catch { return false; } } @@ -85,7 +75,12 @@ async function readRules(rulesDir: string): Promise { // needs the path set; enriching there made every single context-file read do a // full stat+read sweep of the project's context files). async function discoverContextGroups(projectPath: string): Promise { - const slug = ccProjectSlug(projectPath); + // CC slugs realpath(cwd) (see slug-encoding.ts fixture "symlink resolves to + // realpath"). Resolve the same way, falling back exactly as CC's Px() does, + // so a symlinked project folder finds CC's real directory. + let resolved: string; + try { resolved = fs.realpathSync.native(projectPath); } catch { resolved = projectPath; } + const slug = ccProjectSlug(resolved); const projInstr = await findInstructionFiles([projectPath, path.join(projectPath, '.claude')]); const globalInstr = await findInstructionFiles([CLAUDE_DIR]); const projRules = await readRules(path.join(projectPath, '.claude', 'rules')); diff --git a/desktop/src/main/project-conversations.ts b/desktop/src/main/project-conversations.ts index 6f84f5538..94294e6e5 100644 --- a/desktop/src/main/project-conversations.ts +++ b/desktop/src/main/project-conversations.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { listPastSessions, loadHistory } from './session-browser'; -import { cwdToProjectSlug } from './transcript-watcher'; +import { ccProjectSlug } from './slug-encoding'; import type { PastSession, HistoryMessage } from '../shared/types'; const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects'); @@ -17,17 +17,6 @@ const PREVIEW_HEAD_BYTES = 64 * 1024; // turned into a filesystem path (defense against path traversal). const SAFE_ID_RE = /^[A-Za-z0-9._-]+$/; -// CC encodes its project dirs with an UPPERCASE drive letter (C--Users-…), but -// YouCoded's canonical project paths can carry a LOWERCASE drive (c:/Users/…) -// from the artifact index canonicalizer. Uppercase the drive before slugifying -// so the slug matches CC's directory name. Windows paths are case-insensitive, -// so this only normalizes the drive letter; the rest of the path is untouched. -// Without this, project-filtered conversations come back EMPTY on Windows. -export function ccProjectSlug(projectPath: string): string { - const driveNormalized = projectPath.replace(/^([a-z]):/, (_m, d) => `${d.toUpperCase()}:`); - return cwdToProjectSlug(driveNormalized); -} - // Enriched session for the Conversations tab: adds a one-line preview (the first // user message). WHY no messageCount: an exact count needs a full transcript // parse per session — exactly the cost we're removing here. The opened preview @@ -84,7 +73,12 @@ function firstUserPreview(head: string): string { // attach a one-line preview via a BOUNDED head read (not a full transcript // parse). Cheap enough that the hero can call it on every project switch. export async function listProjectConversations(projectPath: string): Promise { - const slug = ccProjectSlug(projectPath); + // CC slugs realpath(cwd) (see slug-encoding.ts fixture "symlink resolves to + // realpath"). Resolve the same way, falling back exactly as CC's Px() does, + // so a symlinked project folder finds CC's real directory. + let resolved: string; + try { resolved = fs.realpathSync.native(projectPath); } catch { resolved = projectPath; } + const slug = ccProjectSlug(resolved); const all = await listPastSessions(); const mine = all.filter((s) => s.projectSlug === slug); return Promise.all( @@ -104,6 +98,10 @@ export async function listProjectConversations(projectPath: string): Promise { - const slug = ccProjectSlug(projectPath); + // CC realpaths the cwd before slugging (probe-verified) — resolve + // caller-supplied paths the same way, falling back to the raw path. + let resolved: string; + try { resolved = fs.realpathSync.native(projectPath); } catch { resolved = projectPath; } + const slug = ccProjectSlug(resolved); return loadHistory(sessionId, slug, count, all); } diff --git a/desktop/src/main/session-browser.ts b/desktop/src/main/session-browser.ts index 75861dc38..2c8a248bc 100644 --- a/desktop/src/main/session-browser.ts +++ b/desktop/src/main/session-browser.ts @@ -3,17 +3,13 @@ import path from 'path'; import os from 'os'; import { PastSession, HistoryMessage, SessionFlagName } from '../shared/types'; // ccProjectSlug drive-normalizes before slugifying, so a store originalPath with -// a lowercase Windows drive still maps to CC's uppercase-drive project dir. Used -// lazily inside listPastSessions, so the session-browser ↔ project-conversations -// import cycle is harmless (neither uses the other at module-eval time). -import { ccProjectSlug } from './project-conversations'; -// Task 5: native rows need the SAME raw-slug encoding NativeSessionHost writes -// under (deliberately NOT ccProjectSlug — see harness/session-store.ts's -// slug-divergence comment). cwdToProjectSlug already lives on transcript-watcher -// and ipc-handlers.ts imports it from here too, so this isn't a new dependency -// direction. -import { cwdToProjectSlug } from './transcript-watcher'; +// a lowercase Windows drive still maps to CC's uppercase-drive project dir. +// nativeStoreSlug is the FROZEN app-private rule for native rows (deliberately +// NOT ccProjectSlug — see harness/session-store.ts's slug-divergence comment). +// Both live on slug-encoding.ts; ipc-handlers.ts imports from there too. +import { ccProjectSlug, nativeStoreSlug, CC_SLUG_MAX } from './slug-encoding'; import type { NativeSessionListEntry } from './harness/session-store'; +import { r1CwdForDir } from './transcript-cwd'; const CLAUDE_DIR = path.join(os.homedir(), '.claude'); const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects'); @@ -28,7 +24,7 @@ const NATIVE_SESSIONS_DIR = path.join(os.homedir(), '.youcoded', 'sessions'); /** The on-disk path for a native session's transcript on THIS device — the * probe listPastSessions uses to decide notSyncedYet for a native row. */ function nativeJsonlPath(cwd: string, sessionId: string): string { - return path.join(NATIVE_SESSIONS_DIR, cwdToProjectSlug(cwd), `${sessionId}.jsonl`); + return path.join(NATIVE_SESSIONS_DIR, nativeStoreSlug(cwd), `${sessionId}.jsonl`); } /** Read per-session metadata from conversation-index.json: the user-set flag @@ -106,14 +102,25 @@ async function withRetry(fn: () => Promise, attempts: number = 3, delayMs: } /** - * Resolves a project slug back to a real filesystem path by walking the - * directory tree. The naive approach (replace all dashes with separators) - * breaks when directory names contain hyphens (e.g. "youcoded-core-dev" - * becomes "youcoded-core/dev"). This function tries each segment greedily - * against the filesystem, extending with hyphens when a single part - * doesn't match a real directory. + * Resolves a project slug back to a real filesystem path via an inversion + * chain (spec §5.4a), each option evidence-stronger than the naive split: + * 1. R1 — the recorded cwd from the slug dir's own transcripts. Exact, not + * inferential, and the ONLY option that still works above CC_SLUG_MAX + * (a capped slug's suffix is a hash of the ORIGINAL path, not more of + * the path itself, so nothing filesystem-side can recover it). + * 2. forwardResolveSlug — walk the filesystem forward, re-slugging real + * child directories and comparing, instead of guessing where the + * original separators were. + * 3. walkSlugParts — legacy longest-first split, kept last so folders that + * already resolved correctly keep resolving identically. */ function resolveSlugToPath(slug: string): string { + const recorded = r1CwdForDir(path.join(PROJECTS_DIR, slug)); + if (recorded) return recorded; + + const forward = forwardResolveSlug(slug); + if (forward) return forward; + let root: string; let parts: string[]; @@ -159,6 +166,51 @@ export function walkSlugParts(base: string, parts: string[]): string { return path.join(base, parts.join('-')); } +/** Option 2 (spec §5.4a): FORWARD re-slug of on-disk candidates. Splitting a + * slug cannot recover ','/'&'/' ' (all collapse to '-'); slugging real child + * dirs forward and prefix-matching can. Longest-encoding-first WITH + * BACKTRACKING — a per-level match can dead-end levels down (siblings `a` + * vs `a-b`, the 57be5e14 shape), so unwind and try the next candidate. + * DECLINES (null) on a capped slug: past 200 chars the slug carries ZERO + * path information, and "search every descendant and hash each" is not a + * confirmation step. Capped slugs are option 1's (recorded cwd) or nothing. */ +export function forwardResolveSlug( + slug: string, + rootsOverride?: { posixRoot?: string; winRoot?: string }, +): string | null { + if (slug.length > CC_SLUG_MAX && slug[CC_SLUG_MAX] === '-') return null; // capped — decline + let base: string; let rest: string; + if (/^[A-Z]--/.test(slug)) { + base = rootsOverride?.winRoot ?? (slug[0] + ':\\'); + rest = slug.slice(3); + } else if (slug.startsWith('-')) { + base = rootsOverride?.posixRoot ?? '/'; + rest = slug.slice(1); + } else return null; + const found = walkForward(base, rest); + if (!found) return null; + // Terminal confirmation: the WHOLE candidate must re-slug to the WHOLE slug + // (lowercased — Windows folder-case drift tolerance, same as buildSlugToName). + return ccProjectSlug(found).toLowerCase() === slug.toLowerCase() ? found : null; +} + +function walkForward(dir: string, rest: string): string | null { + if (rest === '') return dir; + let entries: fs.Dirent[] = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return null; } + const candidates = entries + .filter(e => e.isDirectory()) + .map(e => ({ name: e.name, enc: e.name.replace(/[^a-zA-Z0-9]/g, '-') })) + .filter(c => c.enc.length > 0 && (rest === c.enc || rest.startsWith(c.enc + '-'))) + .sort((a, b) => b.enc.length - a.enc.length); // longest-first, then backtrack + for (const c of candidates) { + const remaining = rest === c.enc ? '' : rest.slice(c.enc.length + 1); + const hit = walkForward(path.join(dir, c.name), remaining); + if (hit) return hit; + } + return null; +} + /** Resolve a session's display name. The auto-title hook writes * `topics/topic-`, but those files are pruned (30-day) and never sync * across devices — so when the file is missing, or still holds the pre-title @@ -357,6 +409,14 @@ export async function listPastSessions( continue; } + // Fix (final review, IMPORTANT 3): resolve once per slug DIRECTORY, not + // once per file inside it. resolveSlugToPath can fall through to R1's + // tier-2 whole-file scan (transcript-cwd.ts), which reads every + // top-level transcript in the dir — invoking it per-file inside + // files.map turned that into an N×N full-file-read multiplier on + // foreign-heavy directories, on the Resume Browser's hot path. + const projectPath = resolveSlugToPath(slug); + const sessionPromises = files.map(async (file) => { const sessionId = file.replace('.jsonl', ''); if (activeSessionIds?.has(sessionId)) return null; @@ -387,7 +447,7 @@ export async function listPastSessions( sessionId, name, projectSlug: slug, - projectPath: resolveSlugToPath(slug), + projectPath, lastModified: meta.lastTimestampMs ?? stat.mtimeMs, size: stat.size, ...(joinedFlags ? { flags: joinedFlags } : {}), @@ -534,14 +594,14 @@ export async function listPastSessions( // $HOME). The store knows the exact projectName, so resolveLocal maps // it by basename with no ambiguity. Only override when that folder // actually holds THIS transcript, so we never point resume elsewhere. - // Native uses cwdToProjectSlug + ~/.youcoded/sessions (its own raw-slug - // convention, deliberately diverging from ccProjectSlug — see + // Native uses nativeStoreSlug + ~/.youcoded/sessions (its own frozen + // slug convention, deliberately diverging from ccProjectSlug — see // harness/session-store.ts). const storeLocal = resolveLocal(rec); if (isNative) { if (storeLocal && fs.existsSync(nativeJsonlPath(storeLocal, rec.id))) { legacy.projectPath = storeLocal; - legacy.projectSlug = cwdToProjectSlug(storeLocal); + legacy.projectSlug = nativeStoreSlug(storeLocal); } } else if (storeLocal && fs.existsSync(path.join(PROJECTS_DIR, ccProjectSlug(storeLocal), `${rec.id}.jsonl`))) { legacy.projectPath = storeLocal; @@ -565,7 +625,7 @@ export async function listPastSessions( result.push({ sessionId: rec.id, name: rec.title || 'Untitled', - projectSlug: localPath ? (isNative ? cwdToProjectSlug(localPath) : ccProjectSlug(localPath)) : '', + projectSlug: localPath ? (isNative ? nativeStoreSlug(localPath) : ccProjectSlug(localPath)) : '', projectPath: localPath ?? rec.originalPath, lastModified: Date.parse(rec.lastActive) || 0, size: 0, diff --git a/desktop/src/main/session-manager.ts b/desktop/src/main/session-manager.ts index dba322a8a..065bb83af 100644 --- a/desktop/src/main/session-manager.ts +++ b/desktop/src/main/session-manager.ts @@ -65,7 +65,12 @@ export class SessionManager extends EventEmitter { // fixed, so this should be rare; the warning makes any regression VISIBLE // instead of a silent wrong-directory resume. Behavior is unchanged. if (!cwdExists && opts.cwd && opts.resumeSessionId) { - console.warn(`[session-manager] resume ${opts.resumeSessionId}: cwd "${opts.cwd}" does not exist — falling back to home; resume may open the wrong project`); + // Persisted breadcrumb (2026-08-12): this was a bare console.warn, which goes + // only to Electron stdout — invisible in a shipped build. log() lands it in + // ~/.claude/desktop.log where the next wrong-resume investigation can find it. + log('WARN', 'SessionManager', 'resume cwd does not exist — falling back to home; resume may open the wrong project', { + resumeSessionId: opts.resumeSessionId, cwd: opts.cwd, + }); } const resolvedCwd = cwdExists ? opts.cwd! : os.homedir(); diff --git a/desktop/src/main/slug-encoding.ts b/desktop/src/main/slug-encoding.ts new file mode 100644 index 000000000..0d632cee3 --- /dev/null +++ b/desktop/src/main/slug-encoding.ts @@ -0,0 +1,60 @@ +// --------------------------------------------------------------------------- +// TWO slug encodings, ON PURPOSE. Read this before "unifying" them. +// +// ccProjectSlug — mirrors Claude Code's ~/.claude/projects// encoding +// bug-for-bug (extracted from the shipped CC 2.1.228 binary). +// Anything that READS or NAMES a directory CC created must +// use this. Its collisions are CC's collisions — never dedup. +// nativeStoreSlug — YouCoded's own FROZEN rule for app-private directories +// (~/.youcoded/sessions//, permissions.json keys). +// Nothing external depends on it; changing it ORPHANS user +// data (native transcripts + remembered Always-allow rules). +// +// History: one shared function (the old transcript-watcher.ts slug helper) +// served both jobs and mirrored CC wrongly, twice (2026-04-23, 2026-08-11). Spec: +// docs/active/specs/2026-08-11-project-slug-encoding-repair.md. +// Guard: tests/slug-encoding.test.ts — anchored to directories a real CC +// created (tests/fixtures/cc-slug-pairs.json), never to this file's output. +// +// Version note (final review, MINOR fold): the rule below was recovered from +// the shipped CC 2.1.228 binary; tests/fixtures/cc-slug-pairs.json was +// independently regenerated against 2.1.229 — behavior is identical across +// both versions, so no divergence to reconcile. +// --------------------------------------------------------------------------- + +export const CC_SLUG_MAX = 200; + +// CC's rolling hash ((h<<5)-h+c | 0). NOTE: there is NO int32-min edge in JS — +// Math.abs takes a double, so Math.abs(-2147483648) === 2147483648 ("zik0zk"). +// CC has no guard; adding one breaks the mirror. (Kotlin's mirror DOES need +// a Long widen — see CcProjectSlug.kt.) +export function ccHash(s: string): string { + let h = 0; + for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; + return Math.abs(h).toString(36); +} + +// Mirrors CC 2.1.228: every non-alphanumeric → '-'; slugs over 200 chars are +// truncated and suffixed with the base36 hash of the ORIGINAL path (not the +// slug). The drive-uppercase pre-step is OUR input normalization for +// YouCoded's canonicalizer emitting `c:/…` — NOT part of CC's rule. Do not +// delete it as "unfaithful": without it, project-filtered conversations and +// the Memory group come back EMPTY on Windows. The hash input is the +// drive-normalized string, deliberately: we hash what we pretend CC saw. +export function ccProjectSlug(cwd: string): string { + const p = cwd.replace(/^([a-z]):/, (_m, d: string) => `${d.toUpperCase()}:`); + const slug = p.replace(/[^a-zA-Z0-9]/g, '-'); + return slug.length <= CC_SLUG_MAX ? slug : `${slug.slice(0, CC_SLUG_MAX)}-${ccHash(p)}`; +} + +// FROZEN — the historical shared slug helper, renamed so its job is +// unmistakable. It names app-private dirs; changing it orphans +// ~/.youcoded/sessions transcripts and every remembered "Always allow" rule. +// It is NOT CC's rule and must never be "fixed" to match one. +export function nativeStoreSlug(cwd: string): string { + return cwd + .replace(/\\/g, '/') + .replace(/:/g, '-') + .replace(/\//g, '-') + .replace(/ /g, '-'); +} diff --git a/desktop/src/main/sync-spaces/import-project.ts b/desktop/src/main/sync-spaces/import-project.ts index 077b025e0..5a3a4f223 100644 --- a/desktop/src/main/sync-spaces/import-project.ts +++ b/desktop/src/main/sync-spaces/import-project.ts @@ -18,7 +18,7 @@ import { canonicalize } from '../../shared/artifacts/canonicalize'; import { updateFolderPath } from '../saved-folders'; import { remapProjectPath } from '../artifacts/central-index'; import { readSidecar, writeSidecar } from '../artifacts/artifact-store'; -import { ccProjectSlug } from '../project-conversations'; +import { ccProjectSlug } from '../slug-encoding'; import type { ManualInclude } from '../../shared/artifacts/types'; export interface ImportCheckOpts { @@ -230,8 +230,15 @@ async function remapSidecarManualPaths(newRoot: string, oldRoot: string): Promis * the new slug dir already exists (rare), merge file-by-file, never clobber. */ function remapTranscriptDir(oldPath: string, newPath: string, claudeDir: string): void { const projectsDir = path.join(claudeDir, 'projects'); - const oldDir = path.join(projectsDir, ccProjectSlug(oldPath)); - const newDir = path.join(projectsDir, ccProjectSlug(newPath)); + // CC slugs realpath(cwd) (see slug-encoding.ts fixture "symlink resolves to + // realpath"). Resolve the same way, falling back exactly as CC's Px() does, + // so a symlinked project folder finds CC's real directory. + let resolvedOld: string; + try { resolvedOld = fs.realpathSync.native(oldPath); } catch { resolvedOld = oldPath; } + let resolvedNew: string; + try { resolvedNew = fs.realpathSync.native(newPath); } catch { resolvedNew = newPath; } + const oldDir = path.join(projectsDir, ccProjectSlug(resolvedOld)); + const newDir = path.join(projectsDir, ccProjectSlug(resolvedNew)); if (!fs.existsSync(oldDir)) return; // no conversations for this folder — nothing to remap if (!fs.existsSync(newDir)) { fs.renameSync(oldDir, newDir); diff --git a/desktop/src/main/transcript-cwd.ts b/desktop/src/main/transcript-cwd.ts new file mode 100644 index 000000000..8ecca9ec9 --- /dev/null +++ b/desktop/src/main/transcript-cwd.ts @@ -0,0 +1,94 @@ +// desktop/src/main/transcript-cwd.ts +// The two transcript-ownership rules from the spec (§5.4). They answer +// DIFFERENT questions and an earlier draft conflated them — read the spec +// section before "simplifying" one into the other. +// R2 firstCwd(file) — "which project does this SESSION belong to?" +// First non-foreign cwd; 200-line cap is safe here +// (observed max first-cwd line: 49). +// R1 r1CwdForDir(dir) — "which path does this slug DIRECTORY encode?" +// Accept only a cwd that re-slugs to the dirname; +// must scan WHOLE files (the motivating fork's +// matching cwd first appears at line 279). +import fs from 'fs'; +import path from 'path'; +import { ccProjectSlug } from './slug-encoding'; + +export const R2_SCAN_CAP = 200; +const HEAD_BYTES = 512 * 1024; + +/** A cwd recorded by a PEER platform's device (materialized transcript) — + * must never be resolved on this one (spec risk 3: 376/648 files here). */ +export function isForeignCwd(cwd: string, platform: NodeJS.Platform = process.platform): boolean { + if (platform === 'win32') return cwd.startsWith('/'); + return /^[A-Za-z]:[\\/]/.test(cwd); +} + +function extractCwd(lineText: string): string | null { + if (!lineText.includes('"cwd"')) return null; + try { + const cwd = (JSON.parse(lineText) as { cwd?: unknown }).cwd; + return typeof cwd === 'string' && cwd ? cwd : null; + } catch { return null; } +} + +/** Bounded head read — R2 never needs more than the first lines, and some + * transcripts are >13MB. */ +function headText(filePath: string): string | null { + try { + const fd = fs.openSync(filePath, 'r'); + try { + const buf = Buffer.alloc(HEAD_BYTES); + const n = fs.readSync(fd, buf, 0, HEAD_BYTES, 0); + return buf.toString('utf8', 0, n); + } finally { fs.closeSync(fd); } + } catch { return null; } +} + +/** R2 — session origin. + * `platform` is a test seam: the foreign-cwd filter is platform-relative + * (see `isForeignCwd`), so callers that need to pin a specific platform + * (tests running fixtures on any CI OS) can override the default of + * `process.platform` here instead of only inside `isForeignCwd` itself — + * otherwise a POSIX fixture silently only tests correctly on POSIX runners + * (this exact gap turned 4 tests wrong on the Windows CI leg). */ +export function firstCwd(filePath: string, platform: NodeJS.Platform = process.platform): string | null { + const head = headText(filePath); + if (head === null) return null; + const lines = head.split('\n').slice(0, R2_SCAN_CAP); + for (const l of lines) { + const cwd = extractCwd(l); + if (cwd && !isForeignCwd(cwd, platform)) return cwd; + } + return null; +} + +/** Every cwd in the file — full read; used by R1's exhaustive tier. */ +export function allCwds(filePath: string, platform: NodeJS.Platform = process.platform): string[] { + let raw: string; + try { raw = fs.readFileSync(filePath, 'utf8'); } catch { return []; } + const out: string[] = []; + for (const l of raw.split('\n')) { + const cwd = extractCwd(l); + if (cwd) out.push(cwd); + } + return out; +} + +/** R1 — directory identity. Tier 1 (cheap): each file's first cwd. Tier 2 + * (exhaustive): every cwd in every file. Lowercased compare matches + * buildSlugToName's Windows case-drift convention (reconciler.ts). */ +export function r1CwdForDir(dirPath: string, platform: NodeJS.Platform = process.platform): string | null { + const dirName = path.basename(dirPath).toLowerCase(); + let files: string[] = []; + try { files = fs.readdirSync(dirPath).filter(f => f.endsWith('.jsonl')); } catch { return null; } + for (const f of files) { + const cwd = firstCwd(path.join(dirPath, f), platform); + if (cwd && ccProjectSlug(cwd).toLowerCase() === dirName) return cwd; + } + for (const f of files) { + for (const cwd of allCwds(path.join(dirPath, f), platform)) { + if (!isForeignCwd(cwd, platform) && ccProjectSlug(cwd).toLowerCase() === dirName) return cwd; + } + } + return null; +} diff --git a/desktop/src/main/transcript-watcher.test.ts b/desktop/src/main/transcript-watcher.test.ts index 822e414d6..5fe4a3d33 100644 --- a/desktop/src/main/transcript-watcher.test.ts +++ b/desktop/src/main/transcript-watcher.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseTranscriptLine, cwdToProjectSlug } from './transcript-watcher'; +import { parseTranscriptLine } from './transcript-watcher'; function makeUserLine( text: string, @@ -144,26 +144,3 @@ describe('transcript-watcher compact-summary forwarding', () => { expect(events[0].data).toEqual({}); }); }); - -describe('cwdToProjectSlug', () => { - it('encodes a Windows path without spaces', () => { - expect(cwdToProjectSlug('C:\\Users\\alice\\repo')).toBe('C--Users-alice-repo'); - }); - - it('encodes a POSIX path without spaces', () => { - expect(cwdToProjectSlug('/home/alice/repo')).toBe('-home-alice-repo'); - }); - - // Regression: CC itself replaces spaces in folder names with dashes, so the - // watcher must do the same or it reads from a non-existent directory and - // chat view stays empty for the whole session. - it('encodes spaces as dashes to match CC (Windows)', () => { - expect(cwdToProjectSlug('C:\\Users\\alice\\PAF 540 Final Data Project')).toBe( - 'C--Users-alice-PAF-540-Final-Data-Project', - ); - }); - - it('encodes spaces as dashes to match CC (POSIX)', () => { - expect(cwdToProjectSlug('/home/alice/My Project')).toBe('-home-alice-My-Project'); - }); -}); diff --git a/desktop/src/main/transcript-watcher.ts b/desktop/src/main/transcript-watcher.ts index 437803c01..16d31a6db 100644 --- a/desktop/src/main/transcript-watcher.ts +++ b/desktop/src/main/transcript-watcher.ts @@ -5,29 +5,7 @@ import { EventEmitter } from 'events'; import { TranscriptEvent } from '../shared/types'; import { SubagentIndex } from './subagent-index'; import { SubagentWatcher } from './subagent-watcher'; - -// --------------------------------------------------------------------------- -// cwdToProjectSlug -// --------------------------------------------------------------------------- - -/** - * Converts a filesystem path to Claude Code's project directory slug. - * e.g. `C:\Users\alice` → `C--Users-alice` - * `/home/user/project` → `-home-user-project` - * `C:\Users\alice\PAF 540 Final` → `C--Users-alice-PAF-540-Final` - * - * Must mirror Claude Code's own encoding exactly — otherwise the watcher points - * at a non-existent directory and chat view stays empty. CC replaces spaces - * with dashes too; we do the same so cwds like "PAF 540 Final Data Project" - * resolve to the right ~/.claude/projects// folder. - */ -export function cwdToProjectSlug(cwd: string): string { - return cwd - .replace(/\\/g, '/') // backslash → forward slash - .replace(/:/g, '-') // colon → dash - .replace(/\//g, '-') // slash → dash - .replace(/ /g, '-'); // space → dash (CC does this too) -} +import { ccProjectSlug } from './slug-encoding'; // --------------------------------------------------------------------------- // parseTranscriptLine @@ -381,14 +359,18 @@ export class TranscriptWatcher extends EventEmitter { /** * Start watching the transcript for a session. */ - startWatching(desktopSessionId: string, claudeSessionId: string, cwd: string): void { + startWatching(desktopSessionId: string, claudeSessionId: string, cwd: string, transcriptPath?: string): void { if (this.sessions.has(desktopSessionId)) { this.stopWatching(desktopSessionId); } - const slug = cwdToProjectSlug(cwd); - const jsonlPath = path.join(this.claudeConfigDir, slug, `${claudeSessionId}.jsonl`); - const subagentsDir = path.join(this.claudeConfigDir, slug, claudeSessionId, 'subagents'); + // Prefer CC's own transcript_path from the hook payload (spec §5.0): no + // character class to get wrong, no cap branch, no drift when CC changes its + // encoding. The slug mirror below is the FALLBACK only (hook payload absent), + // and the subagents dir always rides the transcript's parent. + const jsonlPath = transcriptPath + || path.join(this.claudeConfigDir, ccProjectSlug(cwd), `${claudeSessionId}.jsonl`); + const subagentsDir = path.join(path.dirname(jsonlPath), claudeSessionId, 'subagents'); const subagentIndex = new SubagentIndex(); const subagentWatcher = new SubagentWatcher({ diff --git a/desktop/src/renderer/components/PermissionsSection.tsx b/desktop/src/renderer/components/PermissionsSection.tsx index 74b5351c6..2fc2fda50 100644 --- a/desktop/src/renderer/components/PermissionsSection.tsx +++ b/desktop/src/renderer/components/PermissionsSection.tsx @@ -740,7 +740,7 @@ function RuleRow({ setBusy(true); setNote(null); try { - // The SLUG, never the cwd: cwdToProjectSlug collapses ':', '\', '/' and + // The SLUG, never the cwd: nativeStoreSlug collapses ':', '\', '/' and // spaces all to '-', so a path cannot be reconstructed from a slug and the // store is keyed by the slug alone. const hit = await window.claude.permissions.remove(slug, toPermissionRule(rule)); diff --git a/desktop/src/shared/permission-types.ts b/desktop/src/shared/permission-types.ts index 01c91482f..3394da39d 100644 --- a/desktop/src/shared/permission-types.ts +++ b/desktop/src/shared/permission-types.ts @@ -60,7 +60,7 @@ export interface StoredRule extends PermissionRule { } /** One project's slice of permissions.json, as the management UI reads it. - * `cwd` is absent for entries written before the UI existed: cwdToProjectSlug + * `cwd` is absent for entries written before the UI existed: nativeStoreSlug * collapses ':', '\', '/' AND spaces all to '-', so the original path is NOT * recoverable from the slug. That is why removal keys by slug, not cwd. */ export interface StoredProject { diff --git a/desktop/src/shared/types.ts b/desktop/src/shared/types.ts index 53eb78c81..86ddc87a7 100644 --- a/desktop/src/shared/types.ts +++ b/desktop/src/shared/types.ts @@ -1292,7 +1292,7 @@ export const IPC = { // ---- Remembered "Always allow" rules (M5 2a: permissions management UI) ---- // list = every project's stored grants; remove/remove-project revoke them. // Keyed by PROJECT SLUG, not cwd — permissions.json never stored the cwd for - // pre-existing entries and cwdToProjectSlug is lossy, so the slug is the only + // pre-existing entries and nativeStoreSlug is lossy, so the slug is the only // stable handle the renderer can send back. PERMISSIONS_LIST: 'permissions:list', PERMISSIONS_REMOVE: 'permissions:remove', diff --git a/desktop/tests/conversation-reconciler.test.ts b/desktop/tests/conversation-reconciler.test.ts index 1cfdfac71..f6a3143eb 100644 --- a/desktop/tests/conversation-reconciler.test.ts +++ b/desktop/tests/conversation-reconciler.test.ts @@ -9,7 +9,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { reconcile } from '../src/main/conversations/reconciler'; -import { ccProjectSlug } from '../src/main/project-conversations'; +import { ccProjectSlug } from '../src/main/slug-encoding'; import { createConversationStore, type ConversationStore, diff --git a/desktop/tests/conversations-service-fork-hold.test.ts b/desktop/tests/conversations-service-fork-hold.test.ts new file mode 100644 index 000000000..dc8304a0b --- /dev/null +++ b/desktop/tests/conversations-service-fork-hold.test.ts @@ -0,0 +1,171 @@ +// Pins the fork-hold gate added to conversations/service.ts (found on the +// real-data run, T18 run-3): a session id listed as held in slug-repair's +// state file (surfacedForks — see heldForkIds' WHY in slug-repair-state.ts) +// must be frozen out of BOTH mirror directions — materializeSweep/ +// materializeOne (space -> local) and the reconciler's mirror closure +// (local -> space) — until a human resolves the fork. A non-held session in +// the SAME run must still mirror/materialize normally, proving the hold is +// per-id, not global. Mirrors the mocking setup in +// conversations-service-sweep-pause.test.ts (same collaborators faked via +// vi.hoisted), plus a mock of the new slug-repair-state leaf module. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const h = vi.hoisted(() => { + return { + store: { + upsert: vi.fn(async (_p: any) => ({ id: 'x' })), + get: vi.fn(async () => null), + list: vi.fn(async (_provider: string): Promise => []), + setFlag: vi.fn(async () => {}), + setTitle: vi.fn(async () => {}), + setNote: vi.fn(async () => {}), + remove: vi.fn(async (_provider: string, _id: string) => true), + root: vi.fn(() => ''), + }, + reconcile: vi.fn((_opts: any) => new Promise(() => {})), + mirrorIn: vi.fn((_o: any) => ({ copied: true })), + materializeOut: vi.fn((_o: any) => ({ copied: true })), + syncSpacesSyncNow: vi.fn(async (_spaceId?: string) => ({ ok: true })), + syncSpacesSyncNowAwaited: vi.fn(async (_spaceId?: string, _timeoutMs?: number) => {}), + syncListeners: new Set<(e: any) => void>(), + managedRoots: null as any, + savedFolders: [] as Array<{ path: string }>, + // Fork-hold set the mocked heldForkIds() returns — configurable per test. + heldForks: new Set(), + }; +}); + +vi.mock('../src/main/conversations/conversation-store', () => ({ + createConversationStore: (root: string) => { + h.store.root = vi.fn(() => root); + return h.store; + }, +})); +vi.mock('../src/main/conversations/transcript-mirror', () => ({ + mirrorIn: (o: any) => h.mirrorIn(o), + materializeOut: (o: any) => h.materializeOut(o), +})); +vi.mock('../src/main/conversations/reconciler', () => ({ + reconcile: (o: any) => h.reconcile(o), +})); +vi.mock('../src/main/conversations/slug-repair-state', () => ({ + heldForkIds: (_stateFile?: string) => new Set(h.heldForks), +})); +vi.mock('../src/main/sync-spaces/service', () => ({ + onSyncSpacesEvent: (fn: (e: any) => void) => { + h.syncListeners.add(fn); + return () => h.syncListeners.delete(fn); + }, + syncSpacesSyncNow: (spaceId?: string) => h.syncSpacesSyncNow(spaceId), + syncSpacesSyncNowAwaited: (spaceId?: string, timeoutMs?: number) => h.syncSpacesSyncNowAwaited(spaceId, timeoutMs), + getManagedRoots: () => h.managedRoots, +})); +vi.mock('../src/main/saved-folders', () => ({ + readFolders: () => h.savedFolders, +})); + +function fireSync(e: any): void { + for (const fn of h.syncListeners) fn(e); +} + +let tmpRoot = ''; + +describe('conversations service — fork hold', () => { + beforeEach(() => { + vi.useRealTimers(); + h.syncListeners.clear(); + h.store.upsert.mockReset().mockResolvedValue({ id: 'x' } as any); + h.store.get.mockReset().mockResolvedValue(null as any); + h.store.list.mockReset().mockResolvedValue([]); + h.store.setFlag.mockReset().mockResolvedValue(undefined as any); + h.store.setTitle.mockReset().mockResolvedValue(undefined as any); + h.store.setNote.mockReset().mockResolvedValue(undefined as any); + h.store.remove.mockReset().mockResolvedValue(true as any); + h.reconcile.mockReset().mockImplementation(() => new Promise(() => {})); + h.mirrorIn.mockReset().mockReturnValue({ copied: true } as any); + h.materializeOut.mockReset().mockReturnValue({ copied: true } as any); + h.syncSpacesSyncNow.mockReset().mockResolvedValue({ ok: true } as any); + h.syncSpacesSyncNowAwaited.mockReset().mockResolvedValue(undefined as any); + h.savedFolders = []; + h.heldForks = new Set(); + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'conv-svc-fork-hold-')); + h.managedRoots = { personalRoot: path.join(tmpRoot, 'Personal'), listProjects: () => [] }; + }); + + afterEach(() => { + vi.useRealTimers(); + try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* best-effort */ } + }); + + const startOpts = () => ({ + conversationsRoot: path.join(tmpRoot, 'Conversations'), + projectsDir: path.join(tmpRoot, 'projects'), + topicsDir: path.join(tmpRoot, 'topics'), + device: 'test-device', + }); + + async function freshService() { + vi.resetModules(); + const svc = await import('../src/main/conversations/service'); + await svc.startConversationStore(startOpts()); + return svc; + } + + it('materializeSweep skips a held session (space->local) but still materializes a non-held one in the same run', async () => { + const heldDir = path.join(tmpRoot, 'held-proj'); + const freeDir = path.join(tmpRoot, 'free-proj'); + fs.mkdirSync(heldDir, { recursive: true }); + fs.mkdirSync(freeDir, { recursive: true }); + const heldRec = { + id: 'held-session-id', provider: 'claude', + projectName: 'held-proj', originalPath: heldDir, + transcriptRef: 'claude/transcripts/held-proj/held-session-id.jsonl', + }; + const freeRec = { + id: 'free-session-id', provider: 'claude', + projectName: 'free-proj', originalPath: freeDir, + transcriptRef: 'claude/transcripts/free-proj/free-session-id.jsonl', + }; + h.heldForks = new Set([heldRec.id]); + const svc = await freshService(); + h.store.list.mockImplementation(async (p: string) => (p === 'claude' ? [heldRec, freeRec] : [])); + fireSync({ type: 'synced', spaceId: 'personal', updated: true, pushed: false }); + await vi.waitFor(() => expect(h.materializeOut).toHaveBeenCalled()); + // Only the non-held record materialized — the held one is frozen, even + // though transcript-mirror's grow-only-by-SIZE materializeOut is mocked + // to always "succeed": the hold must stop the CALL from happening at all. + expect(h.materializeOut).toHaveBeenCalledTimes(1); + expect(h.materializeOut.mock.calls[0][0].localJsonlPath).toContain(`${freeRec.id}.jsonl`); + void svc; + }); + + it('materializeOne skips a held session entirely (no quiescence wait, no materializeOut call)', async () => { + const svc = await freshService(); + h.heldForks = new Set(['held-session-id']); + h.store.get.mockResolvedValue({ + id: 'held-session-id', provider: 'claude', + projectName: 'held-proj', originalPath: path.join(tmpRoot, 'held-proj'), + transcriptRef: 'claude/transcripts/held-proj/held-session-id.jsonl', + } as any); + await svc.materializeOne('held-session-id', path.join(tmpRoot, 'held-proj')); + expect(h.materializeOut).not.toHaveBeenCalled(); + }); + + it('the reconciler mirror closure does not mirror a held session local->space, but still mirrors a non-held one', async () => { + h.heldForks = new Set(['held-session-id']); + await freshService(); + expect(h.reconcile).toHaveBeenCalledTimes(1); + const opts = h.reconcile.mock.calls[0][0]; + expect(typeof opts.mirror).toBe('function'); + + opts.mirror(path.join(tmpRoot, 'held-proj', 'held-session-id.jsonl'), 'held-proj', 'held-session-id'); + expect(h.mirrorIn).not.toHaveBeenCalled(); + + opts.mirror(path.join(tmpRoot, 'free-proj', 'free-session-id.jsonl'), 'free-proj', 'free-session-id'); + expect(h.mirrorIn).toHaveBeenCalledTimes(1); + expect(h.mirrorIn.mock.calls[0][0].localJsonlPath).toContain('free-session-id.jsonl'); + }); +}); diff --git a/desktop/tests/conversations-service-sweep-pause.test.ts b/desktop/tests/conversations-service-sweep-pause.test.ts new file mode 100644 index 000000000..1cbf080da --- /dev/null +++ b/desktop/tests/conversations-service-sweep-pause.test.ts @@ -0,0 +1,184 @@ +// Pins the pause/resume gate added to conversations/service.ts (2026-08-15 +// ordering fix): the startup reconcile + materialize sweeps must NOT run +// while pauseSweeps() is active — any trigger while paused (startup kick, +// periodic tick, a Personal 'synced' event) coalesces into ONE deferred run +// once resumeSweeps() lifts the gate. This is what lets main.ts run the +// one-shot slug repair without racing the sweeps (found on the real-data run +// — see pauseSweeps' WHY comment in service.ts). Mirrors the mocking setup in +// conversations-service.test.ts (same collaborators faked via vi.hoisted). +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const h = vi.hoisted(() => { + return { + store: { + upsert: vi.fn(async (_p: any) => ({ id: 'x' })), + get: vi.fn(async () => null), + list: vi.fn(async (_provider: string): Promise => []), + setFlag: vi.fn(async () => {}), + setTitle: vi.fn(async () => {}), + setNote: vi.fn(async () => {}), + remove: vi.fn(async (_provider: string, _id: string) => true), + root: vi.fn(() => ''), + }, + // Unlike conversations-service.test.ts, this file DOES want reconcile to + // resolve (so a resumed run is observable as "completed"), so it defaults + // to an immediately-resolving mock rather than the never-resolving one. + reconcile: vi.fn(async (_opts: any) => 0), + mirrorIn: vi.fn((_o: any) => ({ copied: true })), + materializeOut: vi.fn((_o: any) => ({ copied: true })), + syncSpacesSyncNow: vi.fn(async (_spaceId?: string) => ({ ok: true })), + syncSpacesSyncNowAwaited: vi.fn(async (_spaceId?: string, _timeoutMs?: number) => {}), + syncListeners: new Set<(e: any) => void>(), + managedRoots: null as any, + savedFolders: [] as Array<{ path: string }>, + }; +}); + +vi.mock('../src/main/conversations/conversation-store', () => ({ + createConversationStore: (root: string) => { + h.store.root = vi.fn(() => root); + return h.store; + }, +})); +vi.mock('../src/main/conversations/transcript-mirror', () => ({ + mirrorIn: (o: any) => h.mirrorIn(o), + materializeOut: (o: any) => h.materializeOut(o), +})); +vi.mock('../src/main/conversations/reconciler', () => ({ + reconcile: (o: any) => h.reconcile(o), +})); +vi.mock('../src/main/sync-spaces/service', () => ({ + onSyncSpacesEvent: (fn: (e: any) => void) => { + h.syncListeners.add(fn); + return () => h.syncListeners.delete(fn); + }, + syncSpacesSyncNow: (spaceId?: string) => h.syncSpacesSyncNow(spaceId), + syncSpacesSyncNowAwaited: (spaceId?: string, timeoutMs?: number) => h.syncSpacesSyncNowAwaited(spaceId, timeoutMs), + getManagedRoots: () => h.managedRoots, +})); +vi.mock('../src/main/saved-folders', () => ({ + readFolders: () => h.savedFolders, +})); + +function fireSync(e: any): void { + for (const fn of h.syncListeners) fn(e); +} + +let tmpRoot = ''; + +describe('conversations service — sweep pause/resume gate', () => { + beforeEach(() => { + vi.useRealTimers(); + h.syncListeners.clear(); + h.store.upsert.mockReset().mockResolvedValue({ id: 'x' } as any); + h.store.get.mockReset().mockResolvedValue(null as any); + h.store.list.mockReset().mockResolvedValue([]); + h.store.setFlag.mockReset().mockResolvedValue(undefined as any); + h.store.setTitle.mockReset().mockResolvedValue(undefined as any); + h.store.setNote.mockReset().mockResolvedValue(undefined as any); + h.store.remove.mockReset().mockResolvedValue(true as any); + h.reconcile.mockReset().mockResolvedValue(0 as any); + h.mirrorIn.mockReset().mockReturnValue({ copied: true } as any); + h.materializeOut.mockReset().mockReturnValue({ copied: true } as any); + h.syncSpacesSyncNow.mockReset().mockResolvedValue({ ok: true } as any); + h.syncSpacesSyncNowAwaited.mockReset().mockResolvedValue(undefined as any); + h.savedFolders = []; + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'conv-svc-pause-')); + h.managedRoots = { personalRoot: path.join(tmpRoot, 'Personal'), listProjects: () => [] }; + // Review fix (MINOR): see the identical override in + // conversations-service.test.ts — without it, heldForkIds() calls in + // service.ts read the developer's real ~/.youcoded/slug-repair-state.json. + process.env.YOUCODED_SLUG_REPAIR_STATE = path.join(tmpRoot, 'slug-repair-state.json'); + }); + + afterEach(() => { + vi.useRealTimers(); + delete process.env.YOUCODED_SLUG_REPAIR_STATE; + try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* best-effort */ } + }); + + const startOpts = (extra?: Record) => ({ + conversationsRoot: path.join(tmpRoot, 'Conversations'), + projectsDir: path.join(tmpRoot, 'projects'), + topicsDir: path.join(tmpRoot, 'topics'), + device: 'test-device', + ...extra, + }); + + it('startConversationStore({ pauseSweeps: true }) defers the startup reconcile and materialize; resumeSweeps runs each exactly once', async () => { + vi.resetModules(); + const svc = await import('../src/main/conversations/service'); + await svc.startConversationStore(startOpts({ pauseSweeps: true })); + // Neither startup kick ran while paused. + expect(h.reconcile).not.toHaveBeenCalled(); + expect(h.store.list).not.toHaveBeenCalledWith('native'); + + svc.resumeSweeps(); + await vi.waitFor(() => expect(h.reconcile).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(h.store.list).toHaveBeenCalledWith('native')); + // Exactly one deferred run each — not one per pending trigger. + expect(h.reconcile).toHaveBeenCalledTimes(1); + expect(h.store.list.mock.calls.filter((c) => c[0] === 'native')).toHaveLength(1); + + svc.stopConversationStore(); + }); + + it('a trigger while paused is deferred and coalesced: pause, trigger twice, resume — runs once', async () => { + vi.useFakeTimers(); + vi.resetModules(); + const svc = await import('../src/main/conversations/service'); + // Start WITHOUT pauseSweeps so the startup kicks fire and settle first — + // isolates this test to triggers that arrive AFTER startup. + await svc.startConversationStore(startOpts()); + await vi.waitFor(() => expect(h.reconcile).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(h.store.list).toHaveBeenCalledWith('native')); + h.reconcile.mockClear(); + h.store.list.mockClear(); + + svc.pauseSweeps(); + // Two materialize-eligible 'synced' events while paused. + fireSync({ type: 'synced', spaceId: 'personal', updated: true, pushed: false }); + fireSync({ type: 'synced', spaceId: 'personal', updated: true, pushed: false }); + // Two periodic-reconcile ticks while paused. + await vi.advanceTimersByTimeAsync(30 * 60_000); + await vi.advanceTimersByTimeAsync(30 * 60_000); + // Nothing ran yet — everything is pending. + expect(h.reconcile).not.toHaveBeenCalled(); + expect(h.store.list).not.toHaveBeenCalledWith('native'); + + svc.resumeSweeps(); + await vi.waitFor(() => expect(h.reconcile).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(h.store.list).toHaveBeenCalledWith('native')); + expect(h.reconcile).toHaveBeenCalledTimes(1); // coalesced, not 3 (startup tick excluded + 2 ticks) + expect(h.store.list.mock.calls.filter((c) => c[0] === 'native')).toHaveLength(1); // coalesced, not 2 + + svc.stopConversationStore(); + vi.useRealTimers(); + }); + + it('resumeSweeps() with nothing pending is a no-op and resets the flag', async () => { + vi.resetModules(); + const svc = await import('../src/main/conversations/service'); + await svc.startConversationStore(startOpts()); + await vi.waitFor(() => expect(h.reconcile).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(h.store.list).toHaveBeenCalledWith('native')); + h.reconcile.mockClear(); + h.store.list.mockClear(); + + // Never paused this test — resuming should do nothing. + expect(() => svc.resumeSweeps()).not.toThrow(); + await new Promise((r) => setTimeout(r, 10)); + expect(h.reconcile).not.toHaveBeenCalled(); + expect(h.store.list).not.toHaveBeenCalled(); + + // The flag really did reset: a trigger AFTER this no-op resume runs + // immediately rather than staying stuck "paused". + fireSync({ type: 'synced', spaceId: 'personal', updated: true, pushed: false }); + await vi.waitFor(() => expect(h.store.list).toHaveBeenCalledWith('native')); + + svc.stopConversationStore(); + }); +}); diff --git a/desktop/tests/conversations-service.test.ts b/desktop/tests/conversations-service.test.ts index c7be38b2d..e57c25221 100644 --- a/desktop/tests/conversations-service.test.ts +++ b/desktop/tests/conversations-service.test.ts @@ -10,10 +10,10 @@ import os from 'node:os'; import path from 'node:path'; // Real (pure) — used to compute the exact on-disk transcript path the service // derives, so the quiescence tests can grow the same file the loop stats. -import { ccProjectSlug } from '../src/main/project-conversations'; +import { ccProjectSlug } from '../src/main/slug-encoding'; // Real (pure) — mirrors localJsonlPath's native branch so Task 8 tests can // compute the exact ~/.youcoded/sessions//.jsonl path the service derives. -import { cwdToProjectSlug } from '../src/main/transcript-watcher'; +import { nativeStoreSlug } from '../src/main/slug-encoding'; // vi.mock factories are hoisted above imports, so shared fake state must be // created via vi.hoisted for the factories to close over it. @@ -120,9 +120,18 @@ describe('conversations service composition root', () => { h.savedFolders = []; tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'conv-svc-')); h.managedRoots = { personalRoot: path.join(tmpRoot, 'Personal'), listProjects: () => [] }; + // Review fix (MINOR): service.ts's heldForkIds() calls resolve their + // default state file to ~/.youcoded/slug-repair-state.json with no seam + // plumbed through from here — without this override these tests read the + // DEVELOPER'S REAL state file (non-hermetic; this dev machine's file + // already holds a real fork id, which would silently change what a test + // observes). Points every heldForkIds() call in this suite at a tmp file + // instead. See slug-repair-state.ts's defaultStateFile. + process.env.YOUCODED_SLUG_REPAIR_STATE = path.join(tmpRoot, 'slug-repair-state.json'); }); afterEach(() => { vi.useRealTimers(); + delete process.env.YOUCODED_SLUG_REPAIR_STATE; try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* best-effort */ } }); @@ -775,8 +784,8 @@ describe('conversations service composition root', () => { fs.mkdirSync(dir, { recursive: true }); const id = '44444444-4444-4444-4444-444444444444'; // Seed the local NATIVE transcript at the exact path localJsonlPath derives - // for provider:'native' — ~/.youcoded/sessions//.jsonl. - const localPath = path.join(nativeHomeRoot, '.youcoded', 'sessions', cwdToProjectSlug(dir), `${id}.jsonl`); + // for provider:'native' — ~/.youcoded/sessions//.jsonl. + const localPath = path.join(nativeHomeRoot, '.youcoded', 'sessions', nativeStoreSlug(dir), `${id}.jsonl`); fs.mkdirSync(path.dirname(localPath), { recursive: true }); fs.writeFileSync(localPath, 'native-final-turn'); diff --git a/desktop/tests/fixtures/cc-slug-pairs.json b/desktop/tests/fixtures/cc-slug-pairs.json new file mode 100644 index 000000000..ea52437a4 --- /dev/null +++ b/desktop/tests/fixtures/cc-slug-pairs.json @@ -0,0 +1,14 @@ +{ + "ccVersion": "2.1.229", + "generated": "2026-08-12", + "pairs": [ + { "cwd": "/home/destin/YouCoded/probe/under_score.and.dots", "dir": "-home-destin-YouCoded-probe-under-score-and-dots", "note": "probe: _ and ." }, + { "cwd": "/home/destin/YouCoded/probe/punct (x) + 'y' #z", "dir": "-home-destin-YouCoded-probe-punct--x-----y---z", "note": "probe: punctuation" }, + { "cwd": "/home/destin/YouCoded/probe/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "dir": "-home-destin-YouCoded-probe-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-6bal0v", "note": "probe: over-cap" }, + { "cwd": "/home/destin/YouCoded/probe/real-target", "dir": "-home-destin-YouCoded-probe-real-target", "note": "probe: symlink resolves to realpath" }, + { "cwd": "/home/destin/YouCoded/Projects/PAF 574 - Diversity, Ethics, & Public Change", "dir": "-home-destin-YouCoded-Projects-PAF-574---Diversity--Ethics----Public-Change", "note": "harvest: the reporting folder (comma+ampersand)" }, + { "cwd": "/home/destin", "dir": "-home-destin", "note": "harvest: plain" }, + { "cwd": "/home/destin/youcoded-dev", "dir": "-home-destin-youcoded-dev", "note": "harvest: hyphens are fixed points" }, + { "cwd": "C:\\Users\\alice", "dir": "C--Users-alice", "note": "windows drive+backslash (synthetic, both rules agree)" } + ] +} diff --git a/desktop/tests/native-session-host.test.ts b/desktop/tests/native-session-host.test.ts index f3aae4295..a8b72d8d9 100644 --- a/desktop/tests/native-session-host.test.ts +++ b/desktop/tests/native-session-host.test.ts @@ -4,7 +4,7 @@ import { NativeHome } from '../src/main/native-home'; import { SessionStore } from '../src/main/harness/session-store'; import { NativeSessionHost } from '../src/main/harness/native-session-host'; import { PermissionStore } from '../src/main/harness/permission-store'; -import { cwdToProjectSlug } from '../src/main/transcript-watcher'; +import { nativeStoreSlug } from '../src/main/slug-encoding'; import type { PermissionRule } from '../src/shared/permission-types'; import { MockLanguageModelV4, simulateReadableStream } from 'ai/test'; import { scriptedModel, stream, textChunks, toolCallChunk, finishChunk } from './helpers/scripted-model'; @@ -821,7 +821,7 @@ describe('NativeSessionHost', () => { // whole-object comparison would silently stop dropping the in-memory rule. const stored = await waitForStoredRule(store, root); expect((stored as any).grantedAt).toBeTypeOf('string'); - await expect(p.revokeRule(cwdToProjectSlug(root), stored)).resolves.toBe(true); + await expect(p.revokeRule(nativeStoreSlug(root), stored)).resolves.toBe(true); // Disk is clear AND the SAME still-running session asks again. If revokeRule // had only touched disk, rememberedFor would still grant and this stays false. @@ -831,12 +831,12 @@ describe('NativeSessionHost', () => { }); it('clears sessions whose cwd differs in spelling but shares the slug', async () => { - // cwdToProjectSlug collapses spaces to '-' exactly as it does '/', so these + // nativeStoreSlug collapses spaces to '-' exactly as it does '/', so these // two REAL, distinct directories genuinely share one entry on disk. const spacedCwd = path.join(root, 'my project'); const dashedCwd = path.join(root, 'my-project'); fs.mkdirSync(spacedCwd); fs.mkdirSync(dashedCwd); - expect(cwdToProjectSlug(spacedCwd)).toBe(cwdToProjectSlug(dashedCwd)); // the premise + expect(nativeStoreSlug(spacedCwd)).toBe(nativeStoreSlug(dashedCwd)); // the premise // A store that never grants and never persists: rulesFor is always [], so // the ONLY thing that can make either session grant is its in-memory copy — @@ -857,7 +857,7 @@ describe('NativeSessionHost', () => { expect(await turnAsked(p, 'spaced', 'again')).toBe(false); expect(await turnAsked(p, 'dashed', 'again')).toBe(false); - await p.revokeRule(cwdToProjectSlug(spacedCwd), { tool: 'Write', pattern: 'note.txt', action: 'allow' }); + await p.revokeRule(nativeStoreSlug(spacedCwd), { tool: 'Write', pattern: 'note.txt', action: 'allow' }); // Path equality would have cleared at most one of these. expect(await turnAsked(p, 'spaced', 'after revoke')).toBe(true); @@ -881,7 +881,7 @@ describe('NativeSessionHost', () => { await alwaysAllowTurn(p, 'mine', 'write once'); await alwaysAllowTurn(p, 'other', 'write once'); - await p.revokeRule(cwdToProjectSlug(mineCwd), { tool: 'Write', pattern: 'note.txt', action: 'allow' }); + await p.revokeRule(nativeStoreSlug(mineCwd), { tool: 'Write', pattern: 'note.txt', action: 'allow' }); expect(await turnAsked(p, 'mine', 'after revoke')).toBe(true); expect(await turnAsked(p, 'other', 'after revoke')).toBe(false); // untouched @@ -902,7 +902,7 @@ describe('NativeSessionHost', () => { await p.create({ sessionId: 'quad', cwd: root, binding }); expect(await turnAsked(p, 'quad', 'write once')).toBe(false); // granted by both - await expect(p.revokeRule(cwdToProjectSlug(root), wide)).resolves.toBe(true); + await expect(p.revokeRule(nativeStoreSlug(root), wide)).resolves.toBe(true); // The exact grant survives on disk AND in the still-running session. expect(await store.rulesFor(root)).toMatchObject([{ pattern: 'note.txt', match: 'exact' }]); @@ -926,7 +926,7 @@ describe('NativeSessionHost', () => { await waitForStoredRule(store, root); expect(await turnAsked(p, 's', 'write again')).toBe(false); - await expect(p.revokeProject(cwdToProjectSlug(root))).resolves.toBe(true); + await expect(p.revokeProject(nativeStoreSlug(root))).resolves.toBe(true); expect(await store.list()).toEqual([]); expect(await turnAsked(p, 's', 'after revoke')).toBe(true); diff --git a/desktop/tests/session-browser.test.ts b/desktop/tests/session-browser.test.ts index 87afd2d83..4e1c8c024 100644 --- a/desktop/tests/session-browser.test.ts +++ b/desktop/tests/session-browser.test.ts @@ -3,7 +3,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { createConversationStore } from '../src/main/conversations/conversation-store'; -import { cwdToProjectSlug } from '../src/main/transcript-watcher'; +import { nativeStoreSlug } from '../src/main/slug-encoding'; // Task 7 (store union): session-browser reads the Conversation Store via a // dynamic import of './conversations/service' inside listPastSessions. The real @@ -75,7 +75,7 @@ function nativeEntry(overrides: Partial<{ title: overrides.title, mtimeMs: overrides.mtimeMs ?? Date.parse('2026-06-15T00:00:00Z'), sizeBytes: overrides.sizeBytes ?? 4321, - slug: overrides.slug ?? cwdToProjectSlug(cwd), + slug: overrides.slug ?? nativeStoreSlug(cwd), provider: 'native' as const, }; } @@ -84,7 +84,7 @@ function nativeEntry(overrides: Partial<{ * notSyncedYet-for-native probe (nativeJsonlPath in session-browser.ts) * checks for, mirroring writeTranscript's CC equivalent above. */ function writeNativeTranscript(cwd: string, sid: string): string { - const dir = path.join(tmpHome, '.youcoded', 'sessions', cwdToProjectSlug(cwd)); + const dir = path.join(tmpHome, '.youcoded', 'sessions', nativeStoreSlug(cwd)); fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, `${sid}.jsonl`); fs.writeFileSync(file, JSON.stringify({ v: 1, sessionId: sid, cwd }) + '\n'); @@ -653,7 +653,7 @@ describe('listPastSessions — native rows join the SAME overlay (Task 5)', () = const row = sessions.find((s: any) => s.sessionId === 'native-6'); expect(row?.notSyncedYet).toBeUndefined(); expect(row?.missingProject).toBeUndefined(); - expect(row?.projectSlug).toBe(cwdToProjectSlug(localProj)); + expect(row?.projectSlug).toBe(nativeStoreSlug(localProj)); expect(row?.projectPath).toBe(localProj); }); @@ -681,7 +681,7 @@ describe('listPastSessions — native rows join the SAME overlay (Task 5)', () = const sessions = await listSessions(undefined, entries); const row = sessions.find((s: any) => s.sessionId === 'native-7'); expect(row?.projectPath).toBe(resolvedLocal); - expect(row?.projectSlug).toBe(cwdToProjectSlug(resolvedLocal)); + expect(row?.projectSlug).toBe(nativeStoreSlug(resolvedLocal)); }); it('degrades to bare native rows when store.list() throws', async () => { diff --git a/desktop/tests/session-store.test.ts b/desktop/tests/session-store.test.ts index 24a7adea1..2c412236f 100644 --- a/desktop/tests/session-store.test.ts +++ b/desktop/tests/session-store.test.ts @@ -291,15 +291,14 @@ describe('SessionStore', () => { }); // Task 3 (M2 plan) — pins the DELIBERATE divergence documented at the top of -// session-store.ts: native sessions use the raw cwdToProjectSlug, while the CC -// layer (project-conversations.ts) additionally uppercases a lowercase -// Windows drive letter before slugifying. This is NOT a bug to unify — see -// that file's comment for why (it would orphan existing native transcripts). -describe('native/CC slug divergence', () => { - it('encodes the deliberate slug divergence: native uses raw cwdToProjectSlug, CC layer drive-normalizes', async () => { - const { cwdToProjectSlug } = await import('../src/main/transcript-watcher'); - const { ccProjectSlug } = await import('../src/main/project-conversations'); - expect(cwdToProjectSlug('c:\\Users\\d\\proj')).toBe('c--Users-d-proj'); +// slug-encoding.ts: native sessions use the FROZEN nativeStoreSlug, while the +// CC mirror (ccProjectSlug) additionally uppercases a lowercase Windows drive +// letter before slugifying. This is NOT a bug to unify — see that file's +// comment for why (it would orphan existing native transcripts). +describe('native/CC slug divergence — FREEZE PIN, do not delete', () => { + it('native uses the frozen rule; the CC mirror drive-normalizes', async () => { + const { nativeStoreSlug, ccProjectSlug } = await import('../src/main/slug-encoding'); + expect(nativeStoreSlug('c:\\Users\\d\\proj')).toBe('c--Users-d-proj'); expect(ccProjectSlug('c:\\Users\\d\\proj')).toBe('C--Users-d-proj'); // NOT equal — pinned }); }); diff --git a/desktop/tests/slug-encoding.test.ts b/desktop/tests/slug-encoding.test.ts new file mode 100644 index 000000000..2f82c3514 --- /dev/null +++ b/desktop/tests/slug-encoding.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { ccProjectSlug, nativeStoreSlug, ccHash, CC_SLUG_MAX } from '../src/main/slug-encoding'; + +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, 'fixtures', 'cc-slug-pairs.json'), 'utf8'), +) as { ccVersion: string; pairs: Array<{ cwd: string; dir: string; note: string }> }; + +describe('ccProjectSlug — anchored to directories CC itself created', () => { + for (const p of fixture.pairs) { + it(`${p.note}: ${p.cwd}`, () => { + expect(ccProjectSlug(p.cwd)).toBe(p.dir); + }); + } + + it('caps at 200 and hashes the ORIGINAL argument, not the slug', () => { + const long = '/x/' + 'b'.repeat(300); + const out = ccProjectSlug(long); + // '98hajq' was computed with an INDEPENDENT hash reimplementation at + // review-fix time (2026-08-12) — never with this module's own ccHash + // (spec §2: expected values must not come from the code under test). + expect(out).toBe('-x-' + 'b'.repeat(197) + '-98hajq'); + expect(out.length).toBe(207); + }); + + it('drive-normalizes a lowercase drive (OUR input normalization, not CC rule)', () => { + expect(ccProjectSlug('c:\\Users\\d\\proj')).toBe('C--Users-d-proj'); + }); +}); + +describe('ccHash — pinned so nobody "fixes" a nonexistent int32-min edge', () => { + it('known value', () => { expect(ccHash('abc')).toBe('22ci'); }); + it('empty string', () => { expect(ccHash('')).toBe('0'); }); + // There is NO int32-min trap in JS: Math.abs(-2147483648) === 2147483648 + // ("zik0zk"). CC has no guard; adding one breaks the mirror. (Kotlin DOES + // have the trap — see CcProjectSlugTest.kt.) + it('Math.abs of int32-min is exact in JS', () => { + expect(Math.abs(-2147483648).toString(36)).toBe('zik0zk'); + }); +}); + +describe('nativeStoreSlug — FROZEN (renaming of the old shared slug function)', () => { + // Byte-identical to the historical rule, or the native store and + // permissions.json silently orphan (spec §3). + it('punctuated path keeps , & . _ exactly as before', () => { + expect(nativeStoreSlug('/home/destin/YouCoded/Projects/PAF 574 - Diversity, Ethics, & Public Change')) + .toBe('-home-destin-YouCoded-Projects-PAF-574---Diversity,-Ethics,-&-Public-Change'); + }); + it('encodes the deliberate slug divergence: native raw, CC layer drive-normalizes', () => { + expect(nativeStoreSlug('c:\\Users\\d\\proj')).toBe('c--Users-d-proj'); + expect(ccProjectSlug('c:\\Users\\d\\proj')).toBe('C--Users-d-proj'); // NOT equal — pinned + }); +}); diff --git a/desktop/tests/slug-path-resolution.test.ts b/desktop/tests/slug-path-resolution.test.ts index be36ae809..cc189692c 100644 --- a/desktop/tests/slug-path-resolution.test.ts +++ b/desktop/tests/slug-path-resolution.test.ts @@ -9,7 +9,9 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { walkSlugParts } from '../src/main/session-browser'; +import { walkSlugParts, forwardResolveSlug } from '../src/main/session-browser'; +import { ccProjectSlug } from '../src/main/slug-encoding'; +import { r1CwdForDir } from '../src/main/transcript-cwd'; describe('walkSlugParts', () => { let tmp: string; @@ -44,3 +46,71 @@ describe('walkSlugParts', () => { expect(walkSlugParts(base, ['a', 'b'])).toBe(path.join(base, 'a-b')); }); }); + +// forwardResolveSlug's walk enumerates REAL directory entries (fs.readdirSync), +// so its result can only ever match a canonical path — on macOS os.tmpdir() is +// a symlink (/var/folders/... -> /private/var/folders/...) and on Windows CI +// it can resolve through an 8.3 short name, so the RAW mkdtemp path is never +// what the walk finds. Canonicalize once here and derive the walk's root +// override from the SAME canonical value, per-platform (posixRoot is +// meaningless on Windows, where slugs encode a drive letter instead). +function rootsFor(real: string): { posixRoot?: string; winRoot?: string } { + return process.platform === 'win32' + ? { winRoot: path.parse(real).root } + : { posixRoot: '/' }; +} + +describe('inversion chain (spec §5.4a)', () => { + it('forward walk recovers a punctuated folder the split walk cannot', () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'inv-'))); + const real = path.join(root, 'PAF 574 - Diversity, Ethics, & Public Change'); + fs.mkdirSync(real, { recursive: true }); + const slug = ccProjectSlug(real); + expect(forwardResolveSlug(slug, rootsFor(real))).toBe(real); + }); + + it('BACKTRACKS past sibling a to reach a-b (the 57be5e14 failure shape)', () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'inv-'))); + fs.mkdirSync(path.join(root, 'a', 'x'), { recursive: true }); // wrong subtree exists + const real = path.join(root, 'a-b', 'x'); + fs.mkdirSync(real, { recursive: true }); + expect(forwardResolveSlug(ccProjectSlug(real), rootsFor(real))).toBe(real); + }); + + // The fixture above doesn't actually exercise backtracking: 'a-b' has the + // LONGER encoding ('a-b', len 3) than 'a' (len 1), so longest-first picks + // the winner on the very first try — a greedy "take the top sorted + // candidate, never retry" walk passes it too (verified by hand while + // implementing). This fixture inverts it: the decoy 'a-b' sorts FIRST + // (longer encoding) but is a dead end (no child matches what's left of the + // slug), so only genuine backtracking — unwinding to try the shorter 'a' + // and descending into its real 'b-c' child — reaches the real path. + it('BACKTRACKS off a longer-encoded decoy that dead-ends, onto the shorter real path', () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'inv-'))); + fs.mkdirSync(path.join(root, 'a-b', 'zzz'), { recursive: true }); // decoy: longer encoding, no matching child + const real = path.join(root, 'a', 'b-c'); + fs.mkdirSync(real, { recursive: true }); + expect(forwardResolveSlug(ccProjectSlug(real), rootsFor(real))).toBe(real); + }); + + it('DECLINES on a capped slug instead of returning a plausible wrong path', () => { + const long = '/x/' + 'b'.repeat(300); + const capped = ccProjectSlug(long); + expect(capped.length).toBeGreaterThan(200); + expect(forwardResolveSlug(capped, { posixRoot: '/' })).toBeNull(); + }); + + it('over-cap resolves via option 1 (R1 from a recorded cwd)', () => { + // build a fake projects dir containing the capped slug dir with one transcript + const projects = fs.mkdtempSync(path.join(os.tmpdir(), 'projs-')); + const long = '/x/' + 'b'.repeat(300); + const dir = path.join(projects, ccProjectSlug(long)); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 's.jsonl'), JSON.stringify({ type: 'user', cwd: long }) + '\n'); + // Pin platform explicitly: the fixture's local cwd is a POSIX path, so + // leaving this on process.platform would silently fail on Windows CI + // (see transcript-cwd.test.ts's same seam — adapted here per Task 8's + // platform-seam review fix, which the brief's original snippet predates). + expect(r1CwdForDir(dir, 'linux')).toBe(long); + }); +}); diff --git a/desktop/tests/slug-repair-known-folders.test.ts b/desktop/tests/slug-repair-known-folders.test.ts new file mode 100644 index 000000000..e706316b8 --- /dev/null +++ b/desktop/tests/slug-repair-known-folders.test.ts @@ -0,0 +1,108 @@ +// Pins the fix (found on the first real-data run, 2026-08-15): runSlugRepair's +// default knownFolders assembly must match runReconcile's (conversations/service.ts +// runReconcile) EXACTLY — managed projects FIRST, then saved folders, each source +// individually try-guarded. Before the fix, runSlugRepair only read saved folders +// (~/.claude/youcoded-folders.json), so a MANAGED-only project (never saved) was +// invisible to the repair even though the reconciler buckets by it — the repair +// silently did nothing for that project's mis-filed data. This is a mirror of +// conversations-service.test.ts's "passes managed + saved folder paths as +// knownFolders to the reconciler" test, for the repair side of the same contract. +// +// Isolated in its own file (per the fix plan) because vi.mock on +// sync-spaces/service and saved-folders is module-scoped — slug-repair.test.ts's +// other runSlugRepair tests pass an explicit knownFolders override and must not +// be disturbed by these mocks. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; import os from 'os'; import path from 'path'; +import { ccProjectSlug } from '../src/main/slug-encoding'; +import { createConversationStore } from '../src/main/conversations/conversation-store'; + +// vi.mock factories are hoisted above imports, so shared fake state is created +// via vi.hoisted for the factories to close over. +const h = vi.hoisted(() => ({ + managedProjects: [] as Array<{ name: string; path: string }>, + savedFolders: [] as Array<{ path: string }>, +})); + +vi.mock('../src/main/sync-spaces/service', () => ({ + getManagedRoots: () => ({ listProjects: () => h.managedProjects, personalRoot: '' }), +})); +vi.mock('../src/main/saved-folders', () => ({ + readFolders: () => h.savedFolders, +})); + +import { runSlugRepair, Quarantine } from '../src/main/conversations/slug-repair'; + +describe('runSlugRepair default knownFolders — managed projects + saved folders (matches runReconcile)', () => { + const F = (uuid: string, cwd: string) => JSON.stringify({ type: 'user', uuid, cwd }) + '\n'; + const old = new Date(Date.now() - 60 * 60 * 1000); // aged past LIVE_MTIME_MS so 6.1 doesn't defer it + let home = ''; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'r-known-')); + h.managedProjects = []; + h.savedFolders = []; + }); + afterEach(() => { + try { fs.rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ } + }); + + it('reaches a MANAGED project that is not in saved folders — moves the mis-filed transcript into it', async () => { + const P = path.join(home, 'PAF 574 - Something'); + fs.mkdirSync(P, { recursive: true }); + h.managedProjects = [{ name: path.basename(P), path: P }]; + h.savedFolders = []; // NOT saved — the exact gap that hid the project on the real device + + const projectsDir = path.join(home, '.claude', 'projects'); + const homeSlugDir = path.join(projectsDir, ccProjectSlug(home)); + fs.mkdirSync(homeSlugDir, { recursive: true }); + const wrong = path.join(homeSlugDir, 's1.jsonl'); + fs.writeFileSync(wrong, F('u1', P)); + fs.utimesSync(wrong, old, old); + + const correctDir = path.join(projectsDir, ccProjectSlug(P)); // exists but empty + fs.mkdirSync(correctDir, { recursive: true }); + + const spaceRoot = path.join(home, 'Conversations'); + fs.mkdirSync(path.join(spaceRoot, 'claude', 'transcripts'), { recursive: true }); + const store = createConversationStore(spaceRoot); + const quarantine = new Quarantine(home); + const stateFile = path.join(home, '.youcoded', 'state.json'); + + // NOTE: no knownFolders override — this is the point of the test. It must + // come from runSlugRepair's own default assembly reaching the mocked + // getManagedRoots()/readFolders() the same way runReconcile does. + await runSlugRepair({ projectsDir, homeDir: home, store, spaceRoot, stateFile, quarantine }); + + expect(fs.existsSync(path.join(correctDir, 's1.jsonl'))).toBe(true); // moved to the managed project + expect(fs.existsSync(wrong)).toBe(false); // no longer at the $HOME slug dir + }); + + it('mirror-negative: no managed projects and no saved folders — knownFolders is empty, run is a no-op', async () => { + h.managedProjects = []; + h.savedFolders = []; + const P = path.join(home, 'Some Project'); + fs.mkdirSync(P, { recursive: true }); + + const projectsDir = path.join(home, '.claude', 'projects'); + const homeSlugDir = path.join(projectsDir, ccProjectSlug(home)); + fs.mkdirSync(homeSlugDir, { recursive: true }); + const wrong = path.join(homeSlugDir, 's2.jsonl'); + fs.writeFileSync(wrong, F('u1', P)); + fs.utimesSync(wrong, old, old); + + const correctDir = path.join(projectsDir, ccProjectSlug(P)); + fs.mkdirSync(correctDir, { recursive: true }); + + const spaceRoot = path.join(home, 'Conversations'); + fs.mkdirSync(path.join(spaceRoot, 'claude', 'transcripts'), { recursive: true }); + const store = createConversationStore(spaceRoot); + const quarantine = new Quarantine(home); + const stateFile = path.join(home, '.youcoded', 'state.json'); + + await runSlugRepair({ projectsDir, homeDir: home, store, spaceRoot, stateFile, quarantine }); + + expect(fs.existsSync(wrong)).toBe(true); // untouched + expect(fs.existsSync(path.join(correctDir, 's2.jsonl'))).toBe(false); // nothing moved + }); +}); diff --git a/desktop/tests/slug-repair-sweep-keying.test.ts b/desktop/tests/slug-repair-sweep-keying.test.ts new file mode 100644 index 000000000..f5655837f --- /dev/null +++ b/desktop/tests/slug-repair-sweep-keying.test.ts @@ -0,0 +1,143 @@ +// Task 12 (spec §8 OQ, plan Task 12): pin how the mirror-in sweep BUCKETS a +// materialized transcript when a session already has a conversation record. +// +// Investigation verdict (Step 1, DONE): reconciler.ts's resolveProjectName +// (~lines 63-69) resolves in this order: +// 1. existing?.projectName — the session's RECORD wins +// 2. slugToName.get(slug.toLower) — known-folder exact basename (re-slug match) +// 3. projectNameFromSlug(slug) — lossy last-segment fallback +// RECORD-KEYED, not directory-keyed. A planned data repair (case-C aftermath, +// spec §6.0) builds on "record wins" — this test is the guard that keeps it +// true. Fixture style mirrors tests/conversation-reconciler.test.ts (real tmp +// dirs, real store, no fs mocking). +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { reconcile } from '../src/main/conversations/reconciler'; +import { ccProjectSlug } from '../src/main/slug-encoding'; +import { + createConversationStore, + type ConversationStore, +} from '../src/main/conversations/conversation-store'; + +const SID = '33333333-3333-4333-8333-333333333333'; + +function jsonlLine(obj: Record): string { + return JSON.stringify(obj) + '\n'; +} + +// Same shape as conversation-reconciler.test.ts's writeTranscript: a >500-byte +// transcript with a parseable tail timestamp, so it clears both the junk gate +// and the corrupt-transcript guard. +function writeTranscript(projectsDir: string, slug: string, sid: string, lastTimestamp: string): string { + const dir = path.join(projectsDir, slug); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, `${sid}.jsonl`); + let content = ''; + content += jsonlLine({ type: 'user', isMeta: true, uuid: 'm1', timestamp: '2026-06-01T10:00:00Z', message: { content: 'meta noise' } }); + content += jsonlLine({ + type: 'user', uuid: 'u1', promptId: 'p1', timestamp: '2026-06-01T10:00:01Z', + message: { content: 'fix the slug repair sweep' }, + }); + content += jsonlLine({ + type: 'assistant', uuid: 'a1', timestamp: lastTimestamp, + message: { stop_reason: 'end_turn', content: [{ type: 'text', text: 'done. '.repeat(40) }] }, + }); + fs.writeFileSync(file, content); + return file; +} + +let tmp: string; +let projectsDir: string; +let topicsDir: string; +let store: ConversationStore; +let mirror: ReturnType; + +// Anchor everything to a CONSTRUCTED tree: the slug dir name is +// ccProjectSlug(knownFolderPath), and the known folder's basename is +// deliberately DIFFERENT from any slug-derived name ('wronghint'), so tier 2 +// (slugToName) and tier 3 (last-segment truncation) would both disagree with +// tier 1 (the record). Every expected value below is a literal from this +// construction, never something read back from the reconciler's own output. +const KNOWN_FOLDER = path.join('C:', 'Users', 'someone', 'wronghint'); +const SLUG = ccProjectSlug(KNOWN_FOLDER); // e.g. 'C--Users-someone-wronghint' +const TRANSCRIPT_TIMESTAMP = '2026-06-20T12:00:00Z'; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'yc-sweep-keying-')); + projectsDir = path.join(tmp, '.claude', 'projects'); + topicsDir = path.join(tmp, '.claude', 'topics'); + fs.mkdirSync(projectsDir, { recursive: true }); + fs.mkdirSync(topicsDir, { recursive: true }); + store = createConversationStore(path.join(tmp, 'conversations')); + mirror = vi.fn(); +}); + +afterEach(() => { + try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} +}); + +describe('reconcile — sweep bucketing keys off the record, not the slug directory', () => { + it('tier 1: an existing record\'s projectName wins over a competing tier-2 known-folder hint', async () => { + // Pre-seed a record for this session with a DIFFERENT project name than + // both the known-folder basename ('wronghint') and anything slug-derived, + // and an OLDER lastActive than the transcript, so the upsert branch runs + // (not the freshness-skip branch) and resolveProjectName is exercised on + // the live path, not just the mirror-only skip path. + await store.upsert({ + id: SID, provider: 'claude', projectName: 'RealProj', + device: 'OldDevice', lastActive: '2026-06-01T00:00:00.000Z', title: 'Existing', + }); + writeTranscript(projectsDir, SLUG, SID, TRANSCRIPT_TIMESTAMP); + + const n = await reconcile({ + projectsDir, topicsDir, store, device: 'NewDevice', mirror, + // A known folder whose ccProjectSlug ALSO equals SLUG but whose basename + // is 'wronghint' — stresses the `||` short-circuit: with a genuine + // tier-2 candidate present, only real tier-1 preference (existing + // record truthy) should stop resolveProjectName from falling through. + knownFolders: [KNOWN_FOLDER], + }); + + expect(n).toBe(1); + const rec = await store.get('claude', SID); + expect(rec).not.toBeNull(); + expect(rec!.projectName).toBe('RealProj'); + expect(rec!.transcriptRef).toBe(`claude/transcripts/RealProj/${SID}.jsonl`); + + // The injected mirror callback must receive the SAME record-derived key — + // not the known-folder basename, not a slug-derived name. + expect(mirror).toHaveBeenCalledTimes(1); + expect(mirror).toHaveBeenCalledWith( + path.join(projectsDir, SLUG, `${SID}.jsonl`), + 'RealProj', + SID, + ); + }); + + it('tier 2: with NO existing record, the same known-folder hint DOES win (proves tier 1 genuinely engaged above)', async () => { + // No pre-existing record this time — resolveProjectName's tier 1 + // (existing?.projectName) is falsy, so it must fall through to tier 2 + // (slugToName from knownFolders), landing 'wronghint'. If this variant + // did NOT bucket under 'wronghint', variant A's 'RealProj' result would be + // ambiguous (it could mean tier 1 never even got a chance to compete). + writeTranscript(projectsDir, SLUG, SID, TRANSCRIPT_TIMESTAMP); + + const n = await reconcile({ + projectsDir, topicsDir, store, device: 'NewDevice', mirror, + knownFolders: [KNOWN_FOLDER], + }); + + expect(n).toBe(1); + const rec = await store.get('claude', SID); + expect(rec).not.toBeNull(); + expect(rec!.projectName).toBe('wronghint'); + expect(rec!.transcriptRef).toBe(`claude/transcripts/wronghint/${SID}.jsonl`); + expect(mirror).toHaveBeenCalledWith( + path.join(projectsDir, SLUG, `${SID}.jsonl`), + 'wronghint', + SID, + ); + }); +}); diff --git a/desktop/tests/slug-repair.test.ts b/desktop/tests/slug-repair.test.ts new file mode 100644 index 000000000..277d0c9a7 --- /dev/null +++ b/desktop/tests/slug-repair.test.ts @@ -0,0 +1,957 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; import os from 'os'; import path from 'path'; +import { classifyPair, uuidSet, Quarantine, repairHomeForks, repairRecordsAndSpace, repairOrphanDirs, runSlugRepair } from '../src/main/conversations/slug-repair'; +import { ccProjectSlug, nativeStoreSlug } from '../src/main/slug-encoding'; +import { createConversationStore } from '../src/main/conversations/conversation-store'; +import * as logger from '../src/main/logger'; + +const L = (uuid: string) => JSON.stringify({ type: 'user', uuid, message: {} }) + '\n'; +let tmp: string; +beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'repair-')); }); +afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); +const write = (name: string, content: string) => { + const p = path.join(tmp, name); fs.writeFileSync(p, content); return p; +}; + +describe('classifyPair — the merge-safety contract (spec §6.0)', () => { + it('identical bytes → identical', () => { + const a = write('a.jsonl', L('u1') + L('u2')); + const b = write('b.jsonl', L('u1') + L('u2')); + expect(classifyPair(a, b)).toBe('identical'); + }); + it('strict subset → wrong-is-subset', () => { + const a = write('a.jsonl', L('u1')); + const b = write('b.jsonl', L('u1') + L('u2')); + expect(classifyPair(a, b)).toBe('wrong-is-subset'); + }); + it('strict superset → wrong-is-superset', () => { + const a = write('a.jsonl', L('u1') + L('u2') + L('u3')); + const b = write('b.jsonl', L('u1') + L('u2')); + expect(classifyPair(a, b)).toBe('wrong-is-superset'); + }); + it('bidirectional divergence → fork (NEVER merged)', () => { + const a = write('a.jsonl', L('u1') + L('uA')); + const b = write('b.jsonl', L('u1') + L('uB')); + expect(classifyPair(a, b)).toBe('fork'); + }); + it('equal uuid sets but different bytes (metadata drift) → wrong-is-subset (correct-dir copy wins)', () => { + const a = write('a.jsonl', L('u1') + JSON.stringify({ type: 'mode' }) + '\n'); + const b = write('b.jsonl', L('u1') + JSON.stringify({ type: 'last-prompt' }) + '\n'); + expect(classifyPair(a, b)).toBe('wrong-is-subset'); + }); + it('same uuid, same set, but the shared message content diverges → fork (never subset)', () => { + const a = write('a.jsonl', JSON.stringify({ type: 'user', uuid: 'u1', message: { content: 'truncat' } }) + '\n' + L('u2')); + const b = write('b.jsonl', JSON.stringify({ type: 'user', uuid: 'u1', message: { content: 'truncated properly' } }) + '\n' + L('u2')); + expect(classifyPair(a, b)).toBe('fork'); + }); + it('empty vs empty → identical', () => { + const a = write('a.jsonl', ''); + const b = write('b.jsonl', ''); + expect(classifyPair(a, b)).toBe('identical'); + }); + it('empty wrongCopy vs non-empty correctCopy → wrong-is-subset', () => { + const a = write('a.jsonl', ''); + const b = write('b.jsonl', L('u1') + L('u2')); + expect(classifyPair(a, b)).toBe('wrong-is-subset'); + }); + it('non-empty wrongCopy vs empty correctCopy → wrong-is-superset', () => { + const a = write('a.jsonl', L('u1') + L('u2')); + const b = write('b.jsonl', ''); + expect(classifyPair(a, b)).toBe('wrong-is-superset'); + }); +}); + +describe('Quarantine (spec §6.0)', () => { + it('moves preserving home-relative path and writes the decision log', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'qhome-')); + const victim = path.join(home, '.claude', 'projects', 'slug', 's.jsonl'); + fs.mkdirSync(path.dirname(victim), { recursive: true }); + fs.writeFileSync(victim, 'x'); + const q = new Quarantine(home); + expect(q.move(victim, 'test')).toBe(true); + expect(fs.existsSync(victim)).toBe(false); + expect(fs.readFileSync(path.join(q.dir, '.claude', 'projects', 'slug', 's.jsonl'), 'utf8')).toBe('x'); + expect(fs.readFileSync(path.join(q.dir, 'decisions.log'), 'utf8')).toContain('MOVE'); + }); + it('quarantine root is under .youcoded, NEVER under .claude/projects', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'qhome-')); + const q = new Quarantine(home); + expect(q.dir.startsWith(path.join(home, '.youcoded', 'repair-quarantine'))).toBe(true); + }); +}); + +describe('repairHomeForks (spec §6.1)', () => { + const F = (uuid: string, cwd: string) => JSON.stringify({ type: 'user', uuid, cwd }) + '\n'; + const old = new Date(Date.now() - 60 * 60 * 1000); // 1h ago — not live + const age = (p: string) => fs.utimesSync(p, old, old); + + function makeHome() { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'r61-')); + const P = path.join(home, 'My Proj, & Stuff'); + fs.mkdirSync(P, { recursive: true }); + const projectsDir = path.join(home, '.claude', 'projects'); + const homeSlugDir = path.join(projectsDir, ccProjectSlug(home)); + fs.mkdirSync(homeSlugDir, { recursive: true }); + const quarantine = new Quarantine(home); + const opts = { projectsDir, homeDir: home, knownFolders: [P], quarantine }; + return { home, P, projectsDir, homeSlugDir, quarantine, opts }; + } + + it('R2-owned foreign transcript with NO correct copy is MOVED to the correct dir', () => { + const h = makeHome(); + const f = path.join(h.homeSlugDir, 's1.jsonl'); + fs.writeFileSync(f, F('u1', h.P)); age(f); + const out = repairHomeForks(h.opts); + const dest = path.join(h.projectsDir, ccProjectSlug(h.P), 's1.jsonl'); + expect(out).toEqual([{ sessionId: 's1', homeFolder: h.P, kind: 'moved', paths: [dest] }]); + expect(fs.existsSync(f)).toBe(false); + expect(fs.existsSync(dest)).toBe(true); + }); + + it('identical copy in the $HOME dir is quarantined; correct copy untouched', () => { + const h = makeHome(); + const correctDir = path.join(h.projectsDir, ccProjectSlug(h.P)); + fs.mkdirSync(correctDir, { recursive: true }); + const wrong = path.join(h.homeSlugDir, 's2.jsonl'); + const correct = path.join(correctDir, 's2.jsonl'); + fs.writeFileSync(wrong, F('u1', h.P)); fs.writeFileSync(correct, F('u1', h.P)); + age(wrong); age(correct); + repairHomeForks(h.opts); + expect(fs.existsSync(wrong)).toBe(false); + expect(fs.existsSync(correct)).toBe(true); + expect(fs.existsSync(path.join(h.quarantine.dir, path.relative(h.home, wrong)))).toBe(true); + }); + + it('fork: NOTHING moves — both copies snapshotted, disk byte-identical (§7 merge-safety)', () => { + const h = makeHome(); + const correctDir = path.join(h.projectsDir, ccProjectSlug(h.P)); + fs.mkdirSync(correctDir, { recursive: true }); + const wrong = path.join(h.homeSlugDir, 's3.jsonl'); + const correct = path.join(correctDir, 's3.jsonl'); + fs.writeFileSync(wrong, F('u1', h.P) + F('uA', h.home)); // diverges one way + fs.writeFileSync(correct, F('u1', h.P) + F('uB', h.P)); // …and the other + age(wrong); age(correct); + const before = [fs.readFileSync(wrong, 'utf8'), fs.readFileSync(correct, 'utf8')]; + const out = repairHomeForks(h.opts); + expect(out[0].kind).toBe('fork-surfaced'); + expect(fs.readFileSync(wrong, 'utf8')).toBe(before[0]); + expect(fs.readFileSync(correct, 'utf8')).toBe(before[1]); + expect(fs.readFileSync(path.join(h.quarantine.dir, 'decisions.log'), 'utf8')).toContain('ATTENTION fork s3'); + // (review fix, MINOR) both snapshots physically landed in quarantine. + expect(fs.readFileSync(path.join(h.quarantine.dir, path.relative(h.home, wrong)), 'utf8')).toBe(before[0]); + expect(fs.readFileSync(path.join(h.quarantine.dir, path.relative(h.home, correct)), 'utf8')).toBe(before[1]); + }); + + it('correct-dir copy is a strict subset of the $HOME copy: quarantine it, promote the superset (review fix, IMPORTANT 2a)', () => { + const h = makeHome(); + const correctDir = path.join(h.projectsDir, ccProjectSlug(h.P)); + fs.mkdirSync(correctDir, { recursive: true }); + const wrong = path.join(h.homeSlugDir, 's6.jsonl'); + const correct = path.join(correctDir, 's6.jsonl'); + const supersetBytes = F('u1', h.P) + F('u2', h.P); + const subsetBytes = F('u1', h.P); + fs.writeFileSync(wrong, supersetBytes); + fs.writeFileSync(correct, subsetBytes); + age(wrong); age(correct); + const out = repairHomeForks(h.opts); + expect(out).toEqual([{ sessionId: 's6', homeFolder: h.P, kind: 'replaced-with-superset', paths: [correct] }]); + expect(fs.existsSync(wrong)).toBe(false); + expect(fs.readFileSync(correct, 'utf8')).toBe(supersetBytes); + const quarantinedCorrect = path.join(h.quarantine.dir, path.relative(h.home, correct)); + expect(fs.readFileSync(quarantinedCorrect, 'utf8')).toBe(subsetBytes); + }); + + it('correct-dir copy is superset-eligible but currently live: pair is deferred, nothing moves (review fix, IMPORTANT 2b)', () => { + const h = makeHome(); + const correctDir = path.join(h.projectsDir, ccProjectSlug(h.P)); + fs.mkdirSync(correctDir, { recursive: true }); + const wrong = path.join(h.homeSlugDir, 's7.jsonl'); + const correct = path.join(correctDir, 's7.jsonl'); + const supersetBytes = F('u1', h.P) + F('u2', h.P); + const subsetBytes = F('u1', h.P); + fs.writeFileSync(wrong, supersetBytes); age(wrong); + fs.writeFileSync(correct, subsetBytes); // fresh mtime = live; NOT aged + const out = repairHomeForks(h.opts); + expect(out).toEqual([{ sessionId: 's7', homeFolder: h.P, kind: 'deferred-live', paths: [wrong, correct] }]); + expect(fs.existsSync(wrong)).toBe(true); + expect(fs.readFileSync(wrong, 'utf8')).toBe(supersetBytes); + expect(fs.existsSync(correct)).toBe(true); + expect(fs.readFileSync(correct, 'utf8')).toBe(subsetBytes); + expect(fs.existsSync(h.quarantine.dir)).toBe(false); + }); + + it('top-level only: a subagent jsonl below the dir is never touched (§6.1 scoping)', () => { + const h = makeHome(); + const agent = path.join(h.homeSlugDir, 'sess-id', 'subagents', 'agent-x.jsonl'); + fs.mkdirSync(path.dirname(agent), { recursive: true }); + fs.writeFileSync(agent, F('u1', h.P)); age(agent); + expect(repairHomeForks(h.opts)).toEqual([]); + expect(fs.existsSync(agent)).toBe(true); + }); + + it('live file (fresh mtime) is deferred, not touched (§6.5)', () => { + const h = makeHome(); + const f = path.join(h.homeSlugDir, 's4.jsonl'); + fs.writeFileSync(f, F('u1', h.P)); // fresh mtime = live + const out = repairHomeForks(h.opts); + expect(out).toEqual([{ sessionId: 's4', homeFolder: '', kind: 'deferred-live', paths: [f] }]); + expect(fs.existsSync(f)).toBe(true); + }); + + it('a transcript whose first cwd IS $HOME is left alone (legitimate resident)', () => { + const h = makeHome(); + const f = path.join(h.homeSlugDir, 's5.jsonl'); + fs.writeFileSync(f, F('u1', h.home)); age(f); + expect(repairHomeForks(h.opts)).toEqual([]); + expect(fs.existsSync(f)).toBe(true); + }); +}); + +// Hoisted to file scope (Task 17): shared by repairRecordsAndSpace (§6.2) and +// runSlugRepair (§6.0/§6.5) test blocks. +const F62 = (uuid: string, cwd: string) => JSON.stringify({ type: 'user', uuid, cwd }) + '\n'; +const old62 = new Date(Date.now() - 60 * 60 * 1000); +const age62 = (p: string) => fs.utimesSync(p, old62, old62); + +function makeWorld() { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'r62-')); + const P = path.join(home, 'PAF Proj, & Co'); + fs.mkdirSync(P, { recursive: true }); + const projectsDir = path.join(home, '.claude', 'projects'); + const correctDir = path.join(projectsDir, ccProjectSlug(P)); + fs.mkdirSync(correctDir, { recursive: true }); + const spaceRoot = path.join(home, 'Conversations'); + const lane = path.join(spaceRoot, 'claude', 'transcripts'); + fs.mkdirSync(lane, { recursive: true }); + const store = createConversationStore(spaceRoot); + const quarantine = new Quarantine(home); + const opts = { projectsDir, homeDir: home, knownFolders: [P], quarantine, store, spaceRoot }; + return { home, P, correctDir, lane, store, quarantine, opts, bucket: path.basename(P) }; +} + +describe('repairRecordsAndSpace (spec §6.2)', () => { + const F = F62; + const age = age62; + + it('repairs a record that enshrines $HOME for an R2-owned project session', async () => { + const w = makeWorld(); + const t = path.join(w.correctDir, 's1.jsonl'); + fs.writeFileSync(t, F('u1', w.P)); age(t); + await w.store.upsert({ id: 's1', provider: 'claude', projectName: 'destin', + originalPath: w.home, transcriptRef: 'claude/transcripts/destin/s1.jsonl' }); + await repairRecordsAndSpace(w.opts); + const rec = await w.store.get('claude', 's1'); + expect(rec?.projectName).toBe(w.bucket); + expect(rec?.originalPath).toBe(w.P); + expect(rec?.transcriptRef).toBe(`claude/transcripts/${w.bucket}/s1.jsonl`); + }); + + it('creates a record for a recordless session in a correct project dir', async () => { + const w = makeWorld(); + const t = path.join(w.correctDir, 's2.jsonl'); + fs.writeFileSync(t, F('u1', w.P)); age(t); + await repairRecordsAndSpace(w.opts); + expect((await w.store.get('claude', 's2'))?.projectName).toBe(w.bucket); + }); + + it('keeps the superset space copy, quarantines the truncation-bucket subset — never merges', async () => { + const w = makeWorld(); + fs.mkdirSync(path.join(w.lane, 'Change'), { recursive: true }); + fs.mkdirSync(path.join(w.lane, 'destin'), { recursive: true }); + const small = path.join(w.lane, 'Change', 's3.jsonl'); + const big = path.join(w.lane, 'destin', 's3.jsonl'); + fs.writeFileSync(small, F('u1', w.P)); + fs.writeFileSync(big, F('u1', w.P) + F('u2', w.P)); + age(small); age(big); + await repairRecordsAndSpace(w.opts); + const target = path.join(w.lane, w.bucket, 's3.jsonl'); + expect(fs.existsSync(target)).toBe(true); + expect(uuidSet(target).size).toBe(2); // the superset MOVED — nothing merged + expect(fs.existsSync(small)).toBe(false); // subset quarantined + expect(fs.existsSync(big)).toBe(false); // keeper relocated to the project bucket + }); + + it('a space-only transcript is carried over byte-identical (spec: "CC wins" is undefined for it)', async () => { + const w = makeWorld(); + fs.mkdirSync(path.join(w.lane, 'Change'), { recursive: true }); + const only = path.join(w.lane, 'Change', 's4.jsonl'); + const content = F('u1', w.P); + fs.writeFileSync(only, content); age(only); + await repairRecordsAndSpace(w.opts); + expect(fs.readFileSync(path.join(w.lane, w.bucket, 's4.jsonl'), 'utf8')).toBe(content); + }); + + it('does NOT re-key untouched sessions in a legitimate bucket', async () => { + const w = makeWorld(); + fs.mkdirSync(path.join(w.lane, 'destin'), { recursive: true }); + const homeSession = path.join(w.lane, 'destin', 'sH.jsonl'); + fs.writeFileSync(homeSession, F('u1', w.home)); age(homeSession); // a REAL $HOME session + await w.store.upsert({ id: 'sH', provider: 'claude', projectName: 'destin', + originalPath: w.home, transcriptRef: 'claude/transcripts/destin/sH.jsonl' }); + await repairRecordsAndSpace(w.opts); + expect(fs.existsSync(homeSession)).toBe(true); + expect((await w.store.get('claude', 'sH'))?.projectName).toBe('destin'); + }); + + it('retires an emptied bucket only when it is NOT a known-folder basename', async () => { + const w = makeWorld(); + fs.mkdirSync(path.join(w.lane, 'Change'), { recursive: true }); + const f = path.join(w.lane, 'Change', 's5.jsonl'); + fs.writeFileSync(f, F('u1', w.P)); age(f); + await repairRecordsAndSpace(w.opts); + expect(fs.existsSync(path.join(w.lane, 'Change'))).toBe(false); // emptied fragment retired + expect(fs.existsSync(path.join(w.lane, w.bucket))).toBe(true); // project bucket stays + }); + + // --- Review fix (2026-08-12): fork-gate the space keeper; distinct + // 'record-repaired' finding kind; structurally protect the $HOME bucket --- + + it('three space copies where two are clean subsets of the keeper: moves proceed (fork gate does not misfire)', async () => { + const w = makeWorld(); + fs.mkdirSync(path.join(w.lane, 'A'), { recursive: true }); + fs.mkdirSync(path.join(w.lane, 'B'), { recursive: true }); + fs.mkdirSync(path.join(w.lane, 'C'), { recursive: true }); + const keeperFile = path.join(w.lane, 'A', 's7.jsonl'); + const subset1 = path.join(w.lane, 'B', 's7.jsonl'); + const subset2 = path.join(w.lane, 'C', 's7.jsonl'); + fs.writeFileSync(keeperFile, F('u1', w.P) + F('u2', w.P) + F('u3', w.P)); + fs.writeFileSync(subset1, F('u1', w.P)); + fs.writeFileSync(subset2, F('u2', w.P)); + age(keeperFile); age(subset1); age(subset2); + const out = await repairRecordsAndSpace(w.opts); + const target = path.join(w.lane, w.bucket, 's7.jsonl'); + expect(fs.existsSync(target)).toBe(true); + expect(uuidSet(target).size).toBe(3); + expect(fs.existsSync(subset1)).toBe(false); + expect(fs.existsSync(subset2)).toBe(false); + expect(fs.existsSync(keeperFile)).toBe(false); + expect(out.find(f => f.sessionId === 's7')?.kind).toBe('moved'); + expect((await w.store.get('claude', 's7'))?.projectName).toBe(w.bucket); + }); + + it('two space copies with disjoint uuid sets are an unmerged fork: nothing moves, both snapshotted, no record upsert', async () => { + const w = makeWorld(); + fs.mkdirSync(path.join(w.lane, 'A'), { recursive: true }); + fs.mkdirSync(path.join(w.lane, 'B'), { recursive: true }); + const a = path.join(w.lane, 'A', 's8.jsonl'); + const b = path.join(w.lane, 'B', 's8.jsonl'); + fs.writeFileSync(a, F('u1', w.P) + F('uA', w.P)); + fs.writeFileSync(b, F('u1', w.P) + F('uB', w.P)); + age(a); age(b); + const before = [fs.readFileSync(a, 'utf8'), fs.readFileSync(b, 'utf8')]; + const out = await repairRecordsAndSpace(w.opts); + const found = out.find(f => f.sessionId === 's8'); + expect(found?.kind).toBe('fork-surfaced'); + expect(fs.existsSync(a)).toBe(true); + expect(fs.existsSync(b)).toBe(true); + expect(fs.readFileSync(a, 'utf8')).toBe(before[0]); + expect(fs.readFileSync(b, 'utf8')).toBe(before[1]); + expect(await w.store.get('claude', 's8')).toBeNull(); + }); + + it('two space copies with equal uuid counts but diverging content for a shared uuid: fork, not silently kept', async () => { + const w = makeWorld(); + fs.mkdirSync(path.join(w.lane, 'A'), { recursive: true }); + fs.mkdirSync(path.join(w.lane, 'B'), { recursive: true }); + const a = path.join(w.lane, 'A', 's9.jsonl'); + const b = path.join(w.lane, 'B', 's9.jsonl'); + fs.writeFileSync(a, JSON.stringify({ type: 'user', uuid: 'u1', cwd: w.P, message: { content: 'truncat' } }) + '\n'); + fs.writeFileSync(b, JSON.stringify({ type: 'user', uuid: 'u1', cwd: w.P, message: { content: 'truncated properly' } }) + '\n'); + age(a); age(b); + const before = [fs.readFileSync(a, 'utf8'), fs.readFileSync(b, 'utf8')]; + const out = await repairRecordsAndSpace(w.opts); + const found = out.find(f => f.sessionId === 's9'); + expect(found?.kind).toBe('fork-surfaced'); + expect(fs.readFileSync(a, 'utf8')).toBe(before[0]); + expect(fs.readFileSync(b, 'utf8')).toBe(before[1]); + expect(await w.store.get('claude', 's9')).toBeNull(); + }); + + it('never retires the actual $HOME bucket, even if a move empties it (structural protection, not incidental)', async () => { + const w = makeWorld(); + const homeBucket = path.basename(w.home); + fs.mkdirSync(path.join(w.lane, homeBucket), { recursive: true }); + const f = path.join(w.lane, homeBucket, 's6.jsonl'); + fs.writeFileSync(f, F('u1', w.P)); age(f); // mis-filed under the $HOME bucket, but R2-owned by P + await repairRecordsAndSpace(w.opts); + expect(fs.existsSync(path.join(w.lane, w.bucket, 's6.jsonl'))).toBe(true); // moved to the correct bucket + expect(fs.existsSync(path.join(w.lane, homeBucket))).toBe(true); // $HOME bucket itself survives, empty + }); + + // --- Final review: scope §6.2 to R2-owned sessions; converge to zero findings --- + + it('healthy session (record ok, single copy already at target bucket) is a zero-finding no-op, and STAYS zero on re-run (CRITICAL 1+2)', async () => { + const w = makeWorld(); + const t = path.join(w.correctDir, 's10.jsonl'); + fs.writeFileSync(t, F('u1', w.P)); age(t); + fs.mkdirSync(path.join(w.lane, w.bucket), { recursive: true }); + const spaceCopy = path.join(w.lane, w.bucket, 's10.jsonl'); + fs.writeFileSync(spaceCopy, F('u1', w.P)); age(spaceCopy); + await w.store.upsert({ id: 's10', provider: 'claude', projectName: w.bucket, + originalPath: w.P, transcriptRef: `claude/transcripts/${w.bucket}/s10.jsonl` }); + const recBefore = await w.store.get('claude', 's10'); + + const out1 = await repairRecordsAndSpace(w.opts); + expect(out1).toEqual([]); // ZERO findings — healthy session, nothing to do + expect(await w.store.get('claude', 's10')).toEqual(recBefore); // record untouched + + const out2 = await repairRecordsAndSpace(w.opts); // second run — convergence + expect(out2).toEqual([]); + }); + + it('record already correct + one stray space copy elsewhere: stray is quarantined, but NO RECORD-REPAIR log line and NO record-repaired finding (2026-08-15 real-data fix)', async () => { + const w = makeWorld(); + const t = path.join(w.correctDir, 's12.jsonl'); + fs.writeFileSync(t, F('u1', w.P) + F('u2', w.P)); age(t); + // Keeper copy already sitting at the correct target bucket: + fs.mkdirSync(path.join(w.lane, w.bucket), { recursive: true }); + const keeperCopy = path.join(w.lane, w.bucket, 's12.jsonl'); + fs.writeFileSync(keeperCopy, F('u1', w.P) + F('u2', w.P)); age(keeperCopy); + // A stray duplicate copy filed under the wrong bucket (subset of the keeper, + // so it never contests keeper selection — it's just cleanup): + fs.mkdirSync(path.join(w.lane, 'Stray'), { recursive: true }); + const stray = path.join(w.lane, 'Stray', 's12.jsonl'); + fs.writeFileSync(stray, F('u1', w.P)); age(stray); + // Record already correct — nothing for the repair to change: + await w.store.upsert({ id: 's12', provider: 'claude', projectName: w.bucket, + originalPath: w.P, transcriptRef: `claude/transcripts/${w.bucket}/s12.jsonl` }); + const recBefore = await w.store.get('claude', 's12'); + + const out = await repairRecordsAndSpace(w.opts); + + // Stray copy quarantined (existing behavior) — keeper stays put: + expect(fs.existsSync(stray)).toBe(false); + expect(fs.existsSync(keeperCopy)).toBe(true); + // No RECORD-REPAIR line for this session: + const decisions = fs.readFileSync(path.join(w.quarantine.dir, 'decisions.log'), 'utf8'); + expect(decisions).not.toContain(`RECORD-REPAIR s12`); + // No record-repaired finding, and no 'moved' either (keeper never relocated): + expect(out.find(f => f.sessionId === 's12' && f.kind === 'record-repaired')).toBeUndefined(); + expect(out.find(f => f.sessionId === 's12' && f.kind === 'moved')).toBeUndefined(); + // Record itself is untouched: + expect(await w.store.get('claude', 's12')).toEqual(recBefore); + }); + + it('a transcript whose firstCwd is foreign, sitting in a known folder\'s correct CC dir, is never entered into the repair set (CRITICAL 1)', async () => { + const w = makeWorld(); + const peerCwd = 'C:\\Users\\peer\\proj'; + const t = path.join(w.correctDir, 's11.jsonl'); + fs.writeFileSync(t, F('u1', peerCwd)); age(t); // materialized here, but ORIGINATES on a peer device + await w.store.upsert({ id: 's11', provider: 'claude', projectName: 'peer-project', + originalPath: peerCwd, transcriptRef: 'claude/transcripts/peer-project/s11.jsonl' }); + const recBefore = await w.store.get('claude', 's11'); + + const out = await repairRecordsAndSpace({ ...w.opts, platform: 'linux' }); + expect(out).toEqual([]); // no findings for this session + expect(await w.store.get('claude', 's11')).toEqual(recBefore); // originalPath (the peer's own path) untouched + }); + + // Review fix (Minor 1): when the upsert itself throws, the finding must say + // the RECORD write failed, not the rename — the rename already succeeded + // (or never needed to run). 'rename-failed' read backwards for this site. + it('an upsert failure surfaces record-repair-failed (never rename-failed) — the rename already succeeded', async () => { + const w = makeWorld(); + const t = path.join(w.correctDir, 's13.jsonl'); + fs.writeFileSync(t, F('u1', w.P)); age(t); + // Seed a wrong record so recordChanged is true and the upsert is attempted. + await w.store.upsert({ id: 's13', provider: 'claude', projectName: 'destin', + originalPath: w.home, transcriptRef: 'claude/transcripts/destin/s13.jsonl' }); + const flakyStore = { + ...w.store, + upsert: async (partial: Parameters[0]) => { + if (partial.id === 's13') throw new Error('lock timeout'); + return w.store.upsert(partial); + }, + }; + const out = await repairRecordsAndSpace({ ...w.opts, store: flakyStore as typeof w.store }); + const found = out.find(f => f.sessionId === 's13'); + expect(found?.kind).toBe('record-repair-failed'); + // The old wrong record is still there — the upsert never landed. + expect((await w.store.get('claude', 's13'))?.projectName).toBe('destin'); + }); +}); + +describe('repairOrphanDirs (spec §6.3)', () => { + const F = (uuid: string, cwd: string) => JSON.stringify({ type: 'user', uuid, cwd }) + '\n'; + const old = new Date(Date.now() - 60 * 60 * 1000); // 1h ago — not live + const age = (p: string) => fs.utimesSync(p, old, old); + + // Task 15 makeHome pattern, extended with the orphan-rule dir (nativeStoreSlug) + // alongside the CC-rule correct dir — §6.3 only fires when BOTH exist. + function makeHome() { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'r63-')); + const P = path.join(home, 'My Proj, & Stuff'); + fs.mkdirSync(P, { recursive: true }); + const projectsDir = path.join(home, '.claude', 'projects'); + const correctDir = path.join(projectsDir, ccProjectSlug(P)); + const orphanDir = path.join(projectsDir, nativeStoreSlug(P)); + fs.mkdirSync(correctDir, { recursive: true }); + fs.mkdirSync(orphanDir, { recursive: true }); + const quarantine = new Quarantine(home); + const opts = { projectsDir, homeDir: home, knownFolders: [P], quarantine }; + return { home, P, projectsDir, correctDir, orphanDir, quarantine, opts }; + } + + it('orphan-rule dir with NO matching correct-dir file: session is MOVED to the correct dir', () => { + const h = makeHome(); + const f = path.join(h.orphanDir, 's1.jsonl'); + fs.writeFileSync(f, F('u1', h.P)); age(f); + const out = repairOrphanDirs(h.opts); + const dest = path.join(h.correctDir, 's1.jsonl'); + expect(out).toEqual([{ sessionId: 's1', homeFolder: h.P, kind: 'moved', paths: [dest] }]); + expect(fs.existsSync(f)).toBe(false); + expect(fs.existsSync(dest)).toBe(true); + }); + + it('identical copy in the orphan-rule dir is quarantined; correct copy untouched', () => { + const h = makeHome(); + const wrong = path.join(h.orphanDir, 's2.jsonl'); + const correct = path.join(h.correctDir, 's2.jsonl'); + fs.writeFileSync(wrong, F('u1', h.P)); fs.writeFileSync(correct, F('u1', h.P)); + age(wrong); age(correct); + const out = repairOrphanDirs(h.opts); + expect(out).toEqual([{ sessionId: 's2', homeFolder: h.P, kind: 'quarantined', paths: [wrong] }]); + expect(fs.existsSync(wrong)).toBe(false); + expect(fs.existsSync(correct)).toBe(true); + expect(fs.existsSync(path.join(h.quarantine.dir, path.relative(h.home, wrong)))).toBe(true); + }); + + it('fork: NOTHING moves — both copies snapshotted, disk byte-identical', () => { + const h = makeHome(); + const wrong = path.join(h.orphanDir, 's3.jsonl'); + const correct = path.join(h.correctDir, 's3.jsonl'); + fs.writeFileSync(wrong, F('u1', h.P) + F('uA', h.home)); // diverges one way + fs.writeFileSync(correct, F('u1', h.P) + F('uB', h.P)); // …and the other + age(wrong); age(correct); + const before = [fs.readFileSync(wrong, 'utf8'), fs.readFileSync(correct, 'utf8')]; + const out = repairOrphanDirs(h.opts); + expect(out).toEqual([{ sessionId: 's3', homeFolder: h.P, kind: 'fork-surfaced', paths: [wrong, correct] }]); + expect(fs.readFileSync(wrong, 'utf8')).toBe(before[0]); + expect(fs.readFileSync(correct, 'utf8')).toBe(before[1]); + expect(fs.readFileSync(path.join(h.quarantine.dir, 'decisions.log'), 'utf8')).toContain('ATTENTION fork s3'); + // fork leaves both originals in place — the orphan dir is NOT emptied. + expect(fs.existsSync(h.orphanDir)).toBe(true); + expect(fs.existsSync(wrong)).toBe(true); + }); + + it('correct-dir copy is a strict subset of the orphan copy: quarantine it, promote the superset', () => { + const h = makeHome(); + const wrong = path.join(h.orphanDir, 's4.jsonl'); + const correct = path.join(h.correctDir, 's4.jsonl'); + const supersetBytes = F('u1', h.P) + F('u2', h.P); + const subsetBytes = F('u1', h.P); + fs.writeFileSync(wrong, supersetBytes); + fs.writeFileSync(correct, subsetBytes); + age(wrong); age(correct); + const out = repairOrphanDirs(h.opts); + expect(out).toEqual([{ sessionId: 's4', homeFolder: h.P, kind: 'replaced-with-superset', paths: [correct] }]); + expect(fs.existsSync(wrong)).toBe(false); + expect(fs.readFileSync(correct, 'utf8')).toBe(supersetBytes); + const quarantinedCorrect = path.join(h.quarantine.dir, path.relative(h.home, correct)); + expect(fs.readFileSync(quarantinedCorrect, 'utf8')).toBe(subsetBytes); + }); + + // Parity fix (disclosed adaptation): §6.1 gained a live-guard on the + // correct-dir copy before its superset/fork branches (CRITICAL review fix — + // quarantining/snapshotting a copy CC is actively appending to risks + // stealing the inode out from under an open fd, or capturing a torn write). + // §6.3's `correct` is the SAME CC-tracked file, so it needs the same guard. + it('correct-dir copy is superset-eligible but currently live: pair is deferred, nothing moves', () => { + const h = makeHome(); + const wrong = path.join(h.orphanDir, 's6.jsonl'); + const correct = path.join(h.correctDir, 's6.jsonl'); + const supersetBytes = F('u1', h.P) + F('u2', h.P); + const subsetBytes = F('u1', h.P); + fs.writeFileSync(wrong, supersetBytes); age(wrong); + fs.writeFileSync(correct, subsetBytes); // fresh mtime = live; NOT aged + const out = repairOrphanDirs(h.opts); + expect(out).toEqual([{ sessionId: 's6', homeFolder: h.P, kind: 'deferred-live', paths: [wrong, correct] }]); + expect(fs.existsSync(wrong)).toBe(true); + expect(fs.readFileSync(wrong, 'utf8')).toBe(supersetBytes); + expect(fs.existsSync(correct)).toBe(true); + expect(fs.readFileSync(correct, 'utf8')).toBe(subsetBytes); + }); + + it('fork pair where the correct-dir copy is currently live: pair is deferred, nothing snapshotted', () => { + const h = makeHome(); + const wrong = path.join(h.orphanDir, 's7.jsonl'); + const correct = path.join(h.correctDir, 's7.jsonl'); + fs.writeFileSync(wrong, F('u1', h.P) + F('uA', h.home)); age(wrong); // diverges one way + fs.writeFileSync(correct, F('u1', h.P) + F('uB', h.P)); // …and the other; fresh mtime = live + const out = repairOrphanDirs(h.opts); + expect(out).toEqual([{ sessionId: 's7', homeFolder: h.P, kind: 'deferred-live', paths: [wrong, correct] }]); + expect(fs.existsSync(h.quarantine.dir)).toBe(false); // nothing snapshotted yet + }); + + it('an orphan-rule dir emptied by repair is itself quarantined (never left as a dangling empty dir)', () => { + const h = makeHome(); + const f = path.join(h.orphanDir, 's5.jsonl'); + fs.writeFileSync(f, F('u1', h.P)); age(f); // only file → quarantined case empties the dir + const correct = path.join(h.correctDir, 's5.jsonl'); + fs.writeFileSync(correct, F('u1', h.P)); age(correct); + repairOrphanDirs(h.opts); + expect(fs.existsSync(h.orphanDir)).toBe(false); + expect(fs.existsSync(path.join(h.quarantine.dir, path.relative(h.home, h.orphanDir)))).toBe(true); + expect(fs.readFileSync(path.join(h.quarantine.dir, 'decisions.log'), 'utf8')).toContain('emptied orphan dir'); + }); + + it('when nativeStoreSlug and ccProjectSlug agree for P, the folder is skipped entirely (no orphan possible)', () => { + const h = makeHome(); + // A plain path with no special chars: both slug rules produce the same + // dir name, so there is no separate orphan dir to even look at. + const plain = path.join(h.home, 'PlainProj'); + fs.mkdirSync(plain, { recursive: true }); + const sameDir = path.join(h.projectsDir, ccProjectSlug(plain)); + fs.mkdirSync(sameDir, { recursive: true }); + fs.writeFileSync(path.join(sameDir, 'sX.jsonl'), F('u1', plain)); + const out = repairOrphanDirs({ ...h.opts, knownFolders: [plain] }); + expect(out).toEqual([]); + expect(fs.existsSync(path.join(sameDir, 'sX.jsonl'))).toBe(true); + }); + + it('when only the orphan-rule dir exists (no correct dir) it is left alone — not an orphan pair', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'r63b-')); + const P = path.join(home, 'Only, Orphan'); + fs.mkdirSync(P, { recursive: true }); + const projectsDir = path.join(home, '.claude', 'projects'); + const orphanDir = path.join(projectsDir, nativeStoreSlug(P)); + fs.mkdirSync(orphanDir, { recursive: true }); + // NOTE: correctDir deliberately NOT created. + const f = path.join(orphanDir, 's9.jsonl'); + fs.writeFileSync(f, F('u1', P)); + const quarantine = new Quarantine(home); + const opts = { projectsDir, homeDir: home, knownFolders: [P], quarantine }; + const out = repairOrphanDirs(opts); + expect(out).toEqual([]); + expect(fs.existsSync(f)).toBe(true); + }); +}); + +describe('runSlugRepair — ordering, deferral, surfacing (spec §6.0/§6.5)', () => { + const F = (uuid: string, cwd: string) => JSON.stringify({ type: 'user', uuid, cwd }) + '\n'; + const old = new Date(Date.now() - 60 * 60 * 1000); + const age = (p: string) => fs.utimesSync(p, old, old); + + it('runs 6.2 (space) BEFORE 6.3 (orphan retirement) — the bucket was fed FROM the orphan', async () => { + // Arrange a world where the ONLY space copy sits in a truncation bucket + // and equals the orphan's copy: if 6.3 ran first, the orphan (its origin) + // would be gone before 6.2 relocated the space copy. Assert both outcomes + // hold at the end AND that the orphan file is in quarantine, not deleted. + const w = /* makeWorld() from the 6.2 block, plus: */ (() => { + const base = makeWorld(); + const orphanDir = path.join(base.opts.projectsDir, nativeStoreSlug(base.P)); + fs.mkdirSync(orphanDir, { recursive: true }); + return { ...base, orphanDir }; + })(); + const content = F('u1', w.P); + const correct = path.join(w.correctDir, 's6.jsonl'); + const orphanCopy = path.join(w.orphanDir, 's6.jsonl'); + fs.writeFileSync(correct, content + F('u2', w.P)); // correct is the superset + fs.writeFileSync(orphanCopy, content); + fs.mkdirSync(path.join(w.lane, 'Change'), { recursive: true }); + const spaceCopy = path.join(w.lane, 'Change', 's6.jsonl'); + fs.writeFileSync(spaceCopy, content); + [correct, orphanCopy, spaceCopy].forEach(age); + await runSlugRepair({ ...w.opts, stateFile: path.join(w.home, '.youcoded', 'state.json') }); + expect(fs.existsSync(path.join(w.lane, w.bucket, 's6.jsonl'))).toBe(true); // 6.2 relocated it + expect(fs.existsSync(w.orphanDir)).toBe(false); // 6.3 then retired the orphan + expect(fs.existsSync(orphanCopy)).toBe(false); + expect(fs.existsSync(path.join(w.quarantine.dir, path.relative(w.home, orphanCopy)))).toBe(true); + }); + + it('bounded deferral: 3rd consecutive live deferral writes WARN + ATTENTION', async () => { + const w = makeWorld(); + const homeSlugDir = path.join(w.opts.projectsDir, ccProjectSlug(w.home)); + fs.mkdirSync(homeSlugDir, { recursive: true }); + fs.writeFileSync(path.join(homeSlugDir, 'live1.jsonl'), F('u1', w.P)); // fresh mtime — live + const stateFile = path.join(w.home, '.youcoded', 'state.json'); + await runSlugRepair({ ...w.opts, stateFile }); + await runSlugRepair({ ...w.opts, stateFile }); + await runSlugRepair({ ...w.opts, stateFile }); + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + expect(state.deferred['live1']).toBe(3); + expect(fs.readFileSync(path.join(w.quarantine.dir, 'decisions.log'), 'utf8')).toContain('ATTENTION deferred live1'); + }); + + // Review fix: the deferral contract is per-RUN ("3 runs in a row"), not + // per-finding. A session live in BOTH the $HOME slug dir (6.1's scan) and + // an orphan-dir pair (6.3's scan) in the SAME run must still only count as + // ONE deferral for that run — otherwise it reaches MAX_DEFERRALS in fewer + // real launches than the contract promises. + it('a session live in BOTH the $HOME scan (6.1) and an orphan-dir pair (6.3) is deferred ONCE per run, not once per finding', async () => { + const w = makeWorld(); + const homeSlugDir = path.join(w.opts.projectsDir, ccProjectSlug(w.home)); + fs.mkdirSync(homeSlugDir, { recursive: true }); + fs.writeFileSync(path.join(homeSlugDir, 'dup1.jsonl'), F('u1', w.P)); // fresh mtime — live (6.1 scan) + + const orphanDir = path.join(w.opts.projectsDir, nativeStoreSlug(w.P)); + fs.mkdirSync(orphanDir, { recursive: true }); + fs.writeFileSync(path.join(orphanDir, 'dup1.jsonl'), F('u1', w.P)); // fresh mtime — live (6.3 scan) + + const stateFile = path.join(w.home, '.youcoded', 'state.json'); + await runSlugRepair({ ...w.opts, stateFile }); + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + expect(state.deferred['dup1']).toBe(1); + }); + + it('a surfaced fork gets a store note when the record note is empty', async () => { + const w = makeWorld(); + const homeSlugDir = path.join(w.opts.projectsDir, ccProjectSlug(w.home)); + fs.mkdirSync(homeSlugDir, { recursive: true }); + const wrong = path.join(homeSlugDir, 'sF.jsonl'); + const correct = path.join(w.correctDir, 'sF.jsonl'); + fs.writeFileSync(wrong, F('u1', w.P) + F('uA', w.home)); + fs.writeFileSync(correct, F('u1', w.P) + F('uB', w.P)); + age(wrong); age(correct); + await w.store.upsert({ id: 'sF', provider: 'claude', projectName: w.bucket, originalPath: w.P, transcriptRef: `claude/transcripts/${w.bucket}/sF.jsonl` }); + await runSlugRepair({ ...w.opts, stateFile: path.join(w.home, '.youcoded', 'state.json') }); + expect((await w.store.get('claude', 'sF'))?.note).toContain('two diverged copies'); + }); + + // Review fix (IMPORTANT 1): neither store write in the fork-surfacing path + // may reject the whole run — a throw there must never cost the hold state + // (writeState) that was already computed, or main.ts's + // .finally(resumeSweeps) unpauses the mirror sweeps over an unrecorded + // hold (the exact run-3 clobber this branch exists to prevent). + describe('store-write failures never lose the fork hold (review fix, IMPORTANT 1)', () => { + it('a rejecting setNote does not reject runSlugRepair, and the state file still lists the fork id', async () => { + const w = makeWorld(); + const homeSlugDir = path.join(w.opts.projectsDir, ccProjectSlug(w.home)); + fs.mkdirSync(homeSlugDir, { recursive: true }); + const wrong = path.join(homeSlugDir, 'sG.jsonl'); + const correct = path.join(w.correctDir, 'sG.jsonl'); + fs.writeFileSync(wrong, F('u1', w.P) + F('uA', w.home)); + fs.writeFileSync(correct, F('u1', w.P) + F('uB', w.P)); + age(wrong); age(correct); + // A store whose setNote always rejects (simulates a mutateRecord lock + // timeout — conversation-store.ts:166) — every other method is the + // real store's, so upsert/get behave normally. + const flakyStore = { + ...w.store, + setNote: async () => { throw new Error('conversation-store: could not write claude/sG (lock timeout)'); }, + }; + const stateFile = path.join(w.home, '.youcoded', 'state.json'); + await expect(runSlugRepair({ ...w.opts, store: flakyStore as typeof w.store, stateFile })).resolves.toBeUndefined(); + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + expect(state.surfacedForks).toEqual([{ id: 'sG', paths: [wrong, correct] }]); + }); + + it('a rejecting 6.2 upsert does not reject runSlugRepair — logs an ERROR and surfaces a record-repair-failed finding instead', async () => { + const w = makeWorld(); + const t = path.join(w.correctDir, 'sH.jsonl'); + fs.writeFileSync(t, F('u1', w.P)); age(t); + // Seed a record with the WRONG projectName/originalPath so recordChanged + // is true and repairRecordsAndSpace attempts the upsert. + await w.store.upsert({ id: 'sH', provider: 'claude', projectName: 'destin', originalPath: w.home, transcriptRef: 'claude/transcripts/destin/sH.jsonl' }); + const flakyStore = { + ...w.store, + upsert: async (partial: Parameters[0]) => { + if (partial.id === 'sH') throw new Error('conversation-store: could not write claude/sH (lock timeout)'); + return w.store.upsert(partial); + }, + }; + const stateFile = path.join(w.home, '.youcoded', 'state.json'); + await expect(runSlugRepair({ ...w.opts, store: flakyStore as typeof w.store, stateFile })).resolves.toBeUndefined(); + // The record was NOT repaired (the upsert never landed) — still wrong. + const rec = await w.store.get('claude', 'sH'); + expect(rec?.projectName).toBe('destin'); + const log = fs.readFileSync(path.join(w.quarantine.dir, 'decisions.log'), 'utf8'); + expect(log).toContain('ERROR RECORD-REPAIR sH: upsert failed'); + // The run still completed and wrote state (no exception propagated). + expect(fs.existsSync(stateFile)).toBe(true); + }); + }); + + // Review fix (Minor 2): a throw partway through a LATER stage must never + // discard findings/holds an EARLIER stage already gathered — finalization + // (state write + summary log) is what persists them, and it must still run. + describe('stage isolation — a late-stage throw never drops an earlier stage\'s holds', () => { + it('6.3 throwing still resolves the run, still persists a fork surfaced by 6.1, and still logs the summary', async () => { + const w = makeWorld(); + const homeSlugDir = path.join(w.opts.projectsDir, ccProjectSlug(w.home)); + fs.mkdirSync(homeSlugDir, { recursive: true }); + // A true $HOME fork for 6.1 to surface and hold. + const wrong = path.join(homeSlugDir, 'sI.jsonl'); + const correct = path.join(w.correctDir, 'sI.jsonl'); + fs.writeFileSync(wrong, F('u1', w.P) + F('uA', w.home)); + fs.writeFileSync(correct, F('u1', w.P) + F('uB', w.P)); + age(wrong); age(correct); + const stateFile = path.join(w.home, '.youcoded', 'state.json'); + + const infoSpy = vi.spyOn(logger, 'log'); + const boom = new Error('projectsDir became unreadable mid-scan'); + await expect(runSlugRepair({ + ...w.opts, + stateFile, + stages: { repairOrphanDirs: () => { throw boom; } }, + })).resolves.toBeUndefined(); + + // 6.1's hold survived 6.3's throw — finalization still ran. + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + expect(state.surfacedForks).toEqual([{ id: 'sI', paths: [wrong, correct] }]); + + // The stage failure itself was logged (both to the app log and the + // quarantine decisions log), and the run still completed its summary. + expect(infoSpy).toHaveBeenCalledWith('ERROR', 'SlugRepair', 'stage failed', + expect.objectContaining({ stage: '6.3 repairOrphanDirs', error: expect.stringContaining('projectsDir became unreadable') })); + expect(infoSpy).toHaveBeenCalledWith('INFO', 'SlugRepair', 'repair pass complete', expect.anything()); + const decisions = fs.readFileSync(path.join(w.quarantine.dir, 'decisions.log'), 'utf8'); + expect(decisions).toContain('ERROR stage 6.3 repairOrphanDirs failed'); + + infoSpy.mockRestore(); + }); + }); + + // Fork hold (found on the real-data run, T18 run-3): a surfaced fork must + // stay held across launches — see heldForkIds' WHY in slug-repair-state.ts. + describe('fork hold', () => { + it('a fork-surfaced run writes the session id into surfacedForks in the state file', async () => { + const w = makeWorld(); + const homeSlugDir = path.join(w.opts.projectsDir, ccProjectSlug(w.home)); + fs.mkdirSync(homeSlugDir, { recursive: true }); + const wrong = path.join(homeSlugDir, 'fh1.jsonl'); + const correct = path.join(w.correctDir, 'fh1.jsonl'); + fs.writeFileSync(wrong, F('u1', w.P) + F('uA', w.home)); + fs.writeFileSync(correct, F('u1', w.P) + F('uB', w.P)); + age(wrong); age(correct); + const stateFile = path.join(w.home, '.youcoded', 'state.json'); + await runSlugRepair({ ...w.opts, stateFile }); + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + // Review fix (IMPORTANT 2): surfacedForks now records the fork's paths + // alongside its id, so a later run can tell "user resolved it" (a + // recorded path vanished) apart from "this run's scan just didn't + // reach it" (silence). + expect(state.surfacedForks).toEqual([{ id: 'fh1', paths: [wrong, correct] }]); + }); + + it('a second run on an already-held fork creates no new snapshot files, still surfaces fork-surfaced', async () => { + const w = makeWorld(); + const homeSlugDir = path.join(w.opts.projectsDir, ccProjectSlug(w.home)); + fs.mkdirSync(homeSlugDir, { recursive: true }); + const wrong = path.join(homeSlugDir, 'fh2.jsonl'); + const correct = path.join(w.correctDir, 'fh2.jsonl'); + fs.writeFileSync(wrong, F('u1', w.P) + F('uA', w.home)); + fs.writeFileSync(correct, F('u1', w.P) + F('uB', w.P)); + age(wrong); age(correct); + const stateFile = path.join(w.home, '.youcoded', 'state.json'); + const quarantineRoot = path.join(w.home, '.youcoded', 'repair-quarantine'); + + // Each run gets its OWN quarantine (quarantine: undefined lets + // runSlugRepair default to `new Quarantine(homeDir)`) so the second + // run's directory can be inspected in isolation — a shared quarantine + // would make "no new files" ambiguous with "no files added this call". + await runSlugRepair({ ...w.opts, quarantine: undefined, stateFile }); // run 1 — fresh hold + const dirsAfterFirst = fs.readdirSync(quarantineRoot); + expect(dirsAfterFirst).toHaveLength(1); + const firstDir = path.join(quarantineRoot, dirsAfterFirst[0]); + expect(fs.existsSync(path.join(firstDir, path.relative(w.home, wrong)))).toBe(true); + expect(fs.existsSync(path.join(firstDir, path.relative(w.home, correct)))).toBe(true); + + // Guarantee a distinct ISO-millisecond quarantine dir name for run 2. + await new Promise((r) => setTimeout(r, 5)); + await runSlugRepair({ ...w.opts, quarantine: undefined, stateFile }); // run 2 — already held + const dirsAfterSecond = fs.readdirSync(quarantineRoot).filter((d) => !dirsAfterFirst.includes(d)); + expect(dirsAfterSecond).toHaveLength(1); + const secondDir = path.join(quarantineRoot, dirsAfterSecond[0]); + + const countFiles = (dir: string): number => fs.readdirSync(dir, { withFileTypes: true }) + .reduce((n, e) => n + (e.isDirectory() ? countFiles(path.join(dir, e.name)) : 1), 0); + expect(countFiles(secondDir)).toBe(1); // decisions.log only — no re-snapshotted files + + const log = fs.readFileSync(path.join(secondDir, 'decisions.log'), 'utf8'); + expect(log).toContain('SKIP-SNAPSHOT fork fh2: snapshots already held from a prior run'); + expect(log).toContain('ATTENTION fork fh2'); // still surfaced, not silently dropped + + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + // Still held after run 2 — proves the auto-release logic did NOT drop + // it, which only happens if run 2 actually re-found it as a fork. + expect(state.surfacedForks).toEqual([{ id: 'fh2', paths: [wrong, correct] }]); + }); + + it('a run where the fork is gone (one copy resolved away) drops the id from surfacedForks', async () => { + const w = makeWorld(); + const homeSlugDir = path.join(w.opts.projectsDir, ccProjectSlug(w.home)); + fs.mkdirSync(homeSlugDir, { recursive: true }); + const wrong = path.join(homeSlugDir, 'fh3.jsonl'); + const correct = path.join(w.correctDir, 'fh3.jsonl'); + fs.writeFileSync(wrong, F('u1', w.P) + F('uA', w.home)); + fs.writeFileSync(correct, F('u1', w.P) + F('uB', w.P)); + age(wrong); age(correct); + const stateFile = path.join(w.home, '.youcoded', 'state.json'); + + await runSlugRepair({ ...w.opts, stateFile }); // run 1 — surfaces + holds + let state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + expect(state.surfacedForks).toEqual([{ id: 'fh3', paths: [wrong, correct] }]); + + fs.unlinkSync(wrong); // simulate the user resolving the fork themselves + + await runSlugRepair({ ...w.opts, stateFile }); // run 2 — no longer a fork on disk + state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + // Released via the IMPORTANT 2 "recorded path no longer exists" check — + // 6.1 finds nothing for fh3 this run (the wrong-side file is gone), so + // there's no fresh finding at all; the release is driven purely by + // `wrong` having vanished from disk, not by silence alone. + expect(state.surfacedForks).toEqual([]); + }); + + it('a fork held from a prior run is NOT released when a run silently fails to reach it (both copies still on disk, no finding at all) — review fix, IMPORTANT 2', async () => { + const w = makeWorld(); + const homeSlugDir = path.join(w.opts.projectsDir, ccProjectSlug(w.home)); + fs.mkdirSync(homeSlugDir, { recursive: true }); + const wrong = path.join(homeSlugDir, 'fh4.jsonl'); + const correct = path.join(w.correctDir, 'fh4.jsonl'); + fs.writeFileSync(wrong, F('u1', w.P) + F('uA', w.home)); + fs.writeFileSync(correct, F('u1', w.P) + F('uB', w.P)); + age(wrong); age(correct); + const stateFile = path.join(w.home, '.youcoded', 'state.json'); + + await runSlugRepair({ ...w.opts, stateFile }); // run 1 — surfaces + holds + const state1 = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + expect(state1.surfacedForks).toEqual([{ id: 'fh4', paths: [wrong, correct] }]); + + // Run 2: knownFolders no longer includes P (simulates the user un-saving + // the folder, or readFolders() throwing transiently) — 6.1's scan can't + // even reach fh4 (its P isn't in knownFolders), so this run produces NO + // finding for fh4 at all. Both copies are still on disk untouched. An + // UNRELATED folder Q stands in for P so knownFolders isn't empty (an + // empty list short-circuits runSlugRepair entirely, which would make + // this test pass trivially without exercising the release logic). + const Q = path.join(w.home, 'Other Folder'); + fs.mkdirSync(Q, { recursive: true }); + await runSlugRepair({ ...w.opts, knownFolders: [Q], stateFile }); + const state2 = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + expect(state2.surfacedForks).toEqual([{ id: 'fh4', paths: [wrong, correct] }]); + expect(fs.existsSync(wrong)).toBe(true); + expect(fs.existsSync(correct)).toBe(true); + }); + + it('a fork held from a prior run IS released once the pair converges into a clean subset relation — review fix, IMPORTANT 2', async () => { + const w = makeWorld(); + const homeSlugDir = path.join(w.opts.projectsDir, ccProjectSlug(w.home)); + fs.mkdirSync(homeSlugDir, { recursive: true }); + const wrong = path.join(homeSlugDir, 'fh5.jsonl'); + const correct = path.join(w.correctDir, 'fh5.jsonl'); + fs.writeFileSync(wrong, F('u1', w.P) + F('uA', w.home)); + fs.writeFileSync(correct, F('u1', w.P) + F('uB', w.P)); + age(wrong); age(correct); + const stateFile = path.join(w.home, '.youcoded', 'state.json'); + + await runSlugRepair({ ...w.opts, stateFile }); // run 1 — surfaces + holds + const state1 = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + expect(state1.surfacedForks).toEqual([{ id: 'fh5', paths: [wrong, correct] }]); + + // Simulate the user trimming the wrong-side copy so it's now a clean + // uuid subset of the correct copy (no more diverging uA content) — + // classifyPair now says 'wrong-is-subset' instead of 'fork'. + fs.writeFileSync(wrong, F('u1', w.P)); + age(wrong); + + await runSlugRepair({ ...w.opts, stateFile }); // run 2 — converged, not a fork anymore + const state2 = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + // Released via the IMPORTANT 2 "positive reclassification" check — this + // run produced a 'quarantined' finding for fh5, not a 'fork-surfaced' one. + expect(state2.surfacedForks).toEqual([]); + expect(fs.existsSync(wrong)).toBe(false); // quarantined, not left on disk + }); + }); +}); diff --git a/desktop/tests/sync-spaces-import.test.ts b/desktop/tests/sync-spaces-import.test.ts index c749711ba..e5d04a0ff 100644 --- a/desktop/tests/sync-spaces-import.test.ts +++ b/desktop/tests/sync-spaces-import.test.ts @@ -3,7 +3,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { MAX_IMPORT_FILE_COUNT } from '../src/main/sync-spaces/guards'; -import { ccProjectSlug } from '../src/main/project-conversations'; +import { ccProjectSlug } from '../src/main/slug-encoding'; import { upsertProject, remapProjectPath, listProjects } from '../src/main/artifacts/central-index'; import { canonicalize } from '../src/shared/artifacts/canonicalize'; import { checkImport, countFilesBounded, importProjectFolder } from '../src/main/sync-spaces/import-project'; @@ -12,7 +12,16 @@ import { readSidecar, writeSidecar } from '../src/main/artifacts/artifact-store' import { SIDECAR_SCHEMA_VERSION } from '../src/shared/artifacts/types'; let tmp: string; -beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'yc-import-')); }); +beforeEach(() => { + // Canonicalize: import-project.ts realpaths the destination before computing + // the CC slug dir (that's the dir CC will actually write to — see the + // "CC slugs realpath(cwd)" comment there). On macOS os.tmpdir() is a symlink + // (/var/folders/… -> /private/var/…) and on Windows CI it can resolve + // through an 8.3 short name, so every path this file derives from `tmp` + // must already be canonical or ccProjectSlug(dest) computed here won't + // match the slug dir the code under test actually creates. + tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'yc-import-'))); +}); afterEach(() => fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })); describe('import enablers', () => { diff --git a/desktop/tests/transcript-cwd.test.ts b/desktop/tests/transcript-cwd.test.ts new file mode 100644 index 000000000..cdf0d5171 --- /dev/null +++ b/desktop/tests/transcript-cwd.test.ts @@ -0,0 +1,65 @@ +// desktop/tests/transcript-cwd.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; import os from 'os'; import path from 'path'; +import { isForeignCwd, firstCwd, r1CwdForDir } from '../src/main/transcript-cwd'; +import { ccProjectSlug } from '../src/main/slug-encoding'; + +const line = (o: object) => JSON.stringify(o) + '\n'; +let tmp: string; +beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tcwd-')); }); +afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + +describe('R1 vs R2 — the shape that broke the earlier draft (spec §5.4/§7)', () => { + const PROJ = '/home/u/My Proj, & Stuff'; + const HOME = '/home/u'; + + function writeForkFile(dir: string): string { + // cwd first appears on line 4 (the modal case, 359/648 on the reporting + // device), switches to $HOME at line 279 — PAST R2's 200-line cap. + const f = path.join(dir, 'fork.jsonl'); + const rows: string[] = [line({ type: 'last-prompt' }), line({ type: 'mode' }), line({ type: 'permission-mode' })]; + rows.push(line({ type: 'user', uuid: 'u1', cwd: PROJ })); + for (let i = 0; i < 274; i++) rows.push(line({ type: 'assistant', uuid: `a${i}`, cwd: PROJ })); + for (let i = 0; i < 20; i++) rows.push(line({ type: 'user', uuid: `h${i}`, cwd: HOME })); + fs.writeFileSync(f, rows.join('')); + return f; + } + + it('R2 returns the FIRST cwd (session origin), not the later switch', () => { + const dir = path.join(tmp, ccProjectSlug(HOME)); fs.mkdirSync(dir); + // Pin platform to 'linux' explicitly — these fixtures use POSIX paths as + // the LOCAL cwd, so leaving this on the default process.platform would + // silently only pass on POSIX CI runners (it fails on windows-latest). + expect(firstCwd(writeForkFile(dir), 'linux')).toBe(PROJ); + }); + + it('R1 asked of the $HOME directory finds the LATE matching cwd (line 279 — no cap)', () => { + const dir = path.join(tmp, ccProjectSlug(HOME)); fs.mkdirSync(dir); + writeForkFile(dir); + expect(r1CwdForDir(dir, 'linux')).toBe(HOME); + }); + + it('R1 skips foreign cwds and picks the one that re-slugs to the dirname', () => { + const dir = path.join(tmp, ccProjectSlug(PROJ)); fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, 'win.jsonl'), line({ type: 'user', uuid: 'w', cwd: 'C:\\Users\\desti\\x' })); + fs.writeFileSync(path.join(dir, 'ours.jsonl'), line({ type: 'user', uuid: 'o', cwd: PROJ })); + expect(r1CwdForDir(dir, 'linux')).toBe(PROJ); + }); + + it('R2 skips metadata head lines and foreign values', () => { + const f = path.join(tmp, 'a.jsonl'); + fs.writeFileSync(f, line({ type: 'last-prompt' }) + line({ type: 'user', cwd: 'C:\\Users\\x' }) + line({ type: 'user', cwd: PROJ })); + expect(firstCwd(f, 'linux')).toBe(PROJ); + // Platform inversion, same fixture: under win32 the POSIX cwd becomes + // foreign and the Windows cwd becomes local, so the winner flips. This + // pins the seam itself, not just that it exists. + expect(firstCwd(f, 'win32')).toBe('C:\\Users\\x'); + }); + + it('isForeignCwd: drive-letter on linux, POSIX-absolute on win32', () => { + expect(isForeignCwd('C:\\Users\\x', 'linux')).toBe(true); + expect(isForeignCwd('/home/u', 'linux')).toBe(false); + expect(isForeignCwd('/home/u', 'win32')).toBe(true); + expect(isForeignCwd('C:\\Users\\x', 'win32')).toBe(false); + }); +}); diff --git a/desktop/tests/transcript-watcher.test.ts b/desktop/tests/transcript-watcher.test.ts index 952637ffc..4f28f7996 100644 --- a/desktop/tests/transcript-watcher.test.ts +++ b/desktop/tests/transcript-watcher.test.ts @@ -4,9 +4,9 @@ import os from 'os'; import path from 'path'; import { parseTranscriptLine, - cwdToProjectSlug, TranscriptWatcher, } from '../src/main/transcript-watcher'; +import { ccProjectSlug } from '../src/main/slug-encoding'; import type { TranscriptEvent } from '../src/shared/types'; // Fixed sleep. ONLY valid before a NEGATIVE assertion ("nothing emitted yet") or @@ -284,25 +284,6 @@ describe('parseTranscriptLine', () => { }); }); -// --------------------------------------------------------------------------- -// cwdToProjectSlug -// --------------------------------------------------------------------------- -describe('cwdToProjectSlug', () => { - it('converts Windows path: C:\\Users\\alice → C--Users-alice', () => { - expect(cwdToProjectSlug('C:\\Users\\alice')).toBe('C--Users-alice'); - }); - - it('converts Unix path: /home/user/project → -home-user-project', () => { - expect(cwdToProjectSlug('/home/user/project')).toBe('-home-user-project'); - }); - - it('converts nested Windows path: C:\\Users\\alice\\youcoded-core\\desktop → C--Users-alice-youcoded-core-desktop', () => { - expect(cwdToProjectSlug('C:\\Users\\alice\\youcoded-core\\desktop')).toBe( - 'C--Users-alice-youcoded-core-desktop' - ); - }); -}); - // --------------------------------------------------------------------------- // TranscriptWatcher // --------------------------------------------------------------------------- @@ -328,8 +309,7 @@ describe('TranscriptWatcher', () => { const cwd = 'C:\\Users\\alice'; // Create the project directory and JSONL file - const slug = cwdToProjectSlug(cwd); - const projectDir = path.join(tmpDir, slug); + const projectDir = path.join(tmpDir, 'proj'); fs.mkdirSync(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, `${claudeSessionId}.jsonl`); fs.writeFileSync(jsonlPath, ''); @@ -337,7 +317,7 @@ describe('TranscriptWatcher', () => { const events: TranscriptEvent[] = []; watcher.on('transcript-event', (ev: TranscriptEvent) => events.push(ev)); - watcher.startWatching(desktopSessionId, claudeSessionId, cwd); + watcher.startWatching(desktopSessionId, claudeSessionId, cwd, jsonlPath); // Append a line const line = JSON.stringify({ @@ -371,8 +351,7 @@ describe('TranscriptWatcher', () => { const claudeSessionId = 'claude-session-dedup'; const cwd = '/home/user/project'; - const slug = cwdToProjectSlug(cwd); - const projectDir = path.join(tmpDir, slug); + const projectDir = path.join(tmpDir, 'proj'); fs.mkdirSync(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, `${claudeSessionId}.jsonl`); @@ -393,7 +372,7 @@ describe('TranscriptWatcher', () => { const events: TranscriptEvent[] = []; watcher.on('transcript-event', (ev: TranscriptEvent) => events.push(ev)); - watcher.startWatching(desktopSessionId, claudeSessionId, cwd); + watcher.startWatching(desktopSessionId, claudeSessionId, cwd, jsonlPath); // readNewLines is async — poll for the initial read rather than betting on a // fixed 100ms, which lost under vitest's parallel pool. await vi.waitFor(() => expect(events.length).toBeGreaterThanOrEqual(1), { timeout: SETTLE_MS }); @@ -413,12 +392,12 @@ describe('TranscriptWatcher', () => { const claudeSessionId = 'claude-session-stop'; const cwd = 'C:\\Users\\alice'; - const slug = cwdToProjectSlug(cwd); - const projectDir = path.join(tmpDir, slug); + const projectDir = path.join(tmpDir, 'proj'); fs.mkdirSync(projectDir, { recursive: true }); - fs.writeFileSync(path.join(projectDir, `${claudeSessionId}.jsonl`), ''); + const jsonlPath = path.join(projectDir, `${claudeSessionId}.jsonl`); + fs.writeFileSync(jsonlPath, ''); - watcher.startWatching(desktopSessionId, claudeSessionId, cwd); + watcher.startWatching(desktopSessionId, claudeSessionId, cwd, jsonlPath); watcher.stopWatching(desktopSessionId); // Should not throw when stopping a non-existent session @@ -430,8 +409,7 @@ describe('TranscriptWatcher', () => { const claudeSessionId = 'claude-session-partial'; const cwd = '/home/user/project'; - const slug = cwdToProjectSlug(cwd); - const projectDir = path.join(tmpDir, slug); + const projectDir = path.join(tmpDir, 'proj'); fs.mkdirSync(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, `${claudeSessionId}.jsonl`); @@ -451,7 +429,7 @@ describe('TranscriptWatcher', () => { const events: TranscriptEvent[] = []; watcher.on('transcript-event', (ev: TranscriptEvent) => events.push(ev)); - watcher.startWatching(desktopSessionId, claudeSessionId, cwd); + watcher.startWatching(desktopSessionId, claudeSessionId, cwd, jsonlPath); await wait(200); // negative assertion below — a fixed settle is correct here expect(events).toHaveLength(0); // Incomplete line, no events @@ -467,8 +445,7 @@ describe('TranscriptWatcher', () => { const claudeSessionId = 'claude-session-multi'; const cwd = '/home/user/project'; - const slug = cwdToProjectSlug(cwd); - const projectDir = path.join(tmpDir, slug); + const projectDir = path.join(tmpDir, 'proj'); fs.mkdirSync(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, `${claudeSessionId}.jsonl`); @@ -487,7 +464,7 @@ describe('TranscriptWatcher', () => { const events: TranscriptEvent[] = []; watcher.on('transcript-event', (ev: TranscriptEvent) => events.push(ev)); - watcher.startWatching(desktopSessionId, claudeSessionId, cwd); + watcher.startWatching(desktopSessionId, claudeSessionId, cwd, jsonlPath); // readNewLines is async — poll for the initial read, don't bet on 100ms. await vi.waitFor(() => expect(events).toHaveLength(2), { timeout: SETTLE_MS }); @@ -503,14 +480,16 @@ describe('TranscriptWatcher', () => { const events: TranscriptEvent[] = []; watcher.on('transcript-event', (ev: TranscriptEvent) => events.push(ev)); + // Path is known up front (this is what the hook would hand us) even + // though the file itself doesn't exist yet. + const projectDir = path.join(tmpDir, 'proj'); + const jsonlPath = path.join(projectDir, `${claudeSessionId}.jsonl`); + // Start watching before the file exists — should not throw - watcher.startWatching(desktopSessionId, claudeSessionId, cwd); + watcher.startWatching(desktopSessionId, claudeSessionId, cwd, jsonlPath); // Now create the file - const slug = cwdToProjectSlug(cwd); - const projectDir = path.join(tmpDir, slug); fs.mkdirSync(projectDir, { recursive: true }); - const jsonlPath = path.join(projectDir, `${claudeSessionId}.jsonl`); const line = JSON.stringify({ type: 'assistant', uuid: 'uuid-poll', @@ -538,8 +517,7 @@ describe('TranscriptWatcher', () => { const claudeSessionId = 'claude-session-throw'; const cwd = '/home/user/project'; - const slug = cwdToProjectSlug(cwd); - const projectDir = path.join(tmpDir, slug); + const projectDir = path.join(tmpDir, 'proj'); fs.mkdirSync(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, `${claudeSessionId}.jsonl`); @@ -571,7 +549,7 @@ describe('TranscriptWatcher', () => { received.push(ev.data.text); }); - watcher.startWatching(desktopSessionId, claudeSessionId, cwd); + watcher.startWatching(desktopSessionId, claudeSessionId, cwd, jsonlPath); await vi.waitFor(() => { expect(received).toContain('msg A'); expect(received).toContain('msg C'); @@ -643,12 +621,11 @@ describe('TranscriptWatcher read integrity', () => { function setupSession(desktopId: string, claudeId: string) { const cwd = '/home/user/integrity'; - const slug = cwdToProjectSlug(cwd); - const projectDir = path.join(tmpDir, slug); + const projectDir = path.join(tmpDir, 'proj'); fs.mkdirSync(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, `${claudeId}.jsonl`); fs.writeFileSync(jsonlPath, ''); - watcher.startWatching(desktopId, claudeId, cwd); + watcher.startWatching(desktopId, claudeId, cwd, jsonlPath); return jsonlPath; } @@ -751,3 +728,119 @@ describe('TranscriptWatcher read integrity', () => { expect(texts[0].data.text).toBe('grow'); }); }); + +// --------------------------------------------------------------------------- +// startWatching path source (spec §5.0): hook-supplied transcript_path wins, +// slug derivation is fallback-only. +// --------------------------------------------------------------------------- +describe('startWatching path source (spec §5.0)', () => { + let watcher: TranscriptWatcher; + let tmpDir: string; + + beforeEach(() => { + // Canonicalize: on macOS, FSEvents reports the RESOLVED path (os.tmpdir() + // is a symlink, /var/folders/... -> /private/var/folders/...), so a raw + // mkdtemp path here can silently diverge from what the watcher actually + // sees fire. + tmpDir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'tw-pathsource-'))); + watcher = new TranscriptWatcher(tmpDir); + }); + + afterEach(() => { + watcher.stopAll(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('uses the hook-supplied transcript_path verbatim when present', async () => { + const dir = path.join(tmpDir, 'anything CC chose — no slug involved'); + fs.mkdirSync(dir, { recursive: true }); + const jsonlPath = path.join(dir, 'claude-x.jsonl'); + fs.writeFileSync(jsonlPath, ''); + const events: TranscriptEvent[] = []; + watcher.on('transcript-event', (ev: TranscriptEvent) => events.push(ev)); + watcher.startWatching('desktop-x', 'claude-x', '/some/cwd/that/would/derive/elsewhere', jsonlPath); + fs.appendFileSync(jsonlPath, JSON.stringify({ type: 'user', uuid: 'u1', promptId: 'prompt-u1', message: { role: 'user', content: 'hi' } }) + '\n'); + // Poll instead of a fixed sleep — see the file header: a fixed budget loses + // on a busy runner (this is exactly the macOS-CI flake shape). + await vi.waitFor(() => expect(events.length).toBeGreaterThan(0), { timeout: WATCH_MS }); + }); + + it('falls back to ccProjectSlug derivation when transcript_path is absent', async () => { + const cwd = '/home/user/My Project, With & Punct'; + const projectDir = path.join(tmpDir, ccProjectSlug(cwd)); + fs.mkdirSync(projectDir, { recursive: true }); + const jsonlPath = path.join(projectDir, 'claude-y.jsonl'); + fs.writeFileSync(jsonlPath, ''); + const events: TranscriptEvent[] = []; + watcher.on('transcript-event', (ev: TranscriptEvent) => events.push(ev)); + watcher.startWatching('desktop-y', 'claude-y', cwd); + fs.appendFileSync(jsonlPath, JSON.stringify({ type: 'user', uuid: 'u2', promptId: 'prompt-u2', message: { role: 'user', content: 'hi' } }) + '\n'); + // Poll instead of a fixed sleep — see the file header: a fixed budget loses + // on a busy runner (this is exactly the macOS-CI flake shape). + await vi.waitFor(() => expect(events.length).toBeGreaterThan(0), { timeout: WATCH_MS }); + }); + + // WHY: fallback tests can't distinguish dirname-based from slug-based + // subagentsDir — this divergent path can. Under the 3-arg fallback, + // path.dirname(jsonlPath) and path.join(claudeConfigDir, ccProjectSlug(cwd)) + // are the SAME directory, so a regression that reverted subagentsDir to + // slug-derivation would still pass every other test in this file (including + // the line-~599 "records Agent tool_use" test, which is 3-arg/fallback). + // Here cwd's slug ("C--tmp-project") and the hook-supplied transcript + // directory are deliberately different strings — subagentsDir can only be + // computed correctly by riding jsonlPath's own dirname. + it('subagentsDir follows the hook-supplied transcript path, not slug(cwd)', () => { + const cwd = 'C:/tmp/project'; // slug(cwd) === 'C--tmp-project' — must NOT appear below + const divergentDir = path.join(tmpDir, 'not a slug CC would ever produce'); + fs.mkdirSync(divergentDir, { recursive: true }); + const sessionId = 'sess-hook'; + const parentJsonl = path.join(divergentDir, `${sessionId}.jsonl`); + const subagentsDir = path.join(divergentDir, sessionId, 'subagents'); + fs.mkdirSync(subagentsDir, { recursive: true }); + + fs.writeFileSync(parentJsonl, JSON.stringify({ + type: 'assistant', + uuid: 'uuid-hook-1', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', id: 'toolu_HP1', name: 'Agent', + input: { description: 'Find bug', subagent_type: 'Explore', prompt: 'go' }, + }], + stop_reason: null, + }, + }) + '\n'); + + fs.writeFileSync( + path.join(subagentsDir, 'agent-hook.meta.json'), + JSON.stringify({ description: 'Find bug', agentType: 'Explore' }), + ); + fs.writeFileSync( + path.join(subagentsDir, 'agent-hook.jsonl'), + JSON.stringify({ + type: 'assistant', uuid: 'uuid-hook-s1', isSidechain: true, + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_HS1', name: 'Read', input: { file_path: '/a' } }], + stop_reason: null, + }, + }) + '\n', + ); + + // 4-arg: transcriptPath (parentJsonl, inside divergentDir) wins over any + // cwd-derived slug. If subagentsDir regressed to slug-derivation, it would + // look under tmpDir/C--tmp-project/sess-hook/subagents instead — a + // directory that was never created — and the subagent tool_use below + // would never be found. + watcher.startWatching('desktop-hook-1', sessionId, cwd, parentJsonl); + + const history = watcher.getHistory('desktop-hook-1'); + + const parentToolUse = history.find(e => e.type === 'tool-use' && e.data.toolName === 'Agent'); + const subagentToolUse = history.find(e => e.type === 'tool-use' && e.data.toolName === 'Read'); + expect(parentToolUse).toBeDefined(); + expect(subagentToolUse).toBeDefined(); + expect(subagentToolUse!.data.parentAgentToolUseId).toBe('toolu_HP1'); + expect(subagentToolUse!.data.agentId).toBe('hook'); + }); +}); diff --git a/docs/cc-dependencies.md b/docs/cc-dependencies.md index 44cf4d45e..3413148da 100644 --- a/docs/cc-dependencies.md +++ b/docs/cc-dependencies.md @@ -255,8 +255,20 @@ Update this table when you re-run snapshots after a CC version bump. Anything th - **CC-coupled files:** - `desktop/src/main/project-context.ts` — reads project + global `CLAUDE.md`/`AGENTS.md`, `.claude/rules/*.md` (frontmatter `globs:`), and `~/.claude/projects//memory/` (`MEMORY.md` index + per-fact notes) - `desktop/src/main/project/context-discovery.ts` — pure mapper that classifies each file's load timing - - `desktop/src/main/project-conversations.ts` — uses `cwdToProjectSlug` to filter `listPastSessions()` to one project, and `loadHistory()` to read JSONL transcripts for the no-launch preview + - `desktop/src/main/project-conversations.ts` — uses `ccProjectSlug` to filter `listPastSessions()` to one project, and `loadHistory()` to read JSONL transcripts for the no-launch preview - `desktop/src/shared/project-context-types.ts` — `RECOGNIZED_INSTRUCTION_FILES` (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`) -- **Depends on CC's:** project-slug directory layout (`~/.claude/projects//`, encoded by `cwdToProjectSlug`); the `CLAUDE.md` / `.claude/rules/` instruction-file + project-memory conventions; the `memory/MEMORY.md` index format; and the JSONL transcript shape consumed by `loadHistory` (already covered by the JSONL transcript-location entry above). +- **Depends on CC's:** project-slug directory layout (`~/.claude/projects//`, encoded by `ccProjectSlug` — `cwdToProjectSlug` was the old four-character mirror, deleted by the 2026-08-11 slug-encoding-repair; see the dedicated coupling entry below); the `CLAUDE.md` / `.claude/rules/` instruction-file + project-memory conventions; the `memory/MEMORY.md` index format; and the JSONL transcript shape consumed by `loadHistory` (already covered by the JSONL transcript-location entry above). - **Break symptom:** If CC changes the slug encoding, the Context tab shows no memory and the Conversations tab shows no sessions for a project (slug points at a non-existent dir). If CC relocates project memory or changes the instruction-file discovery (e.g. stops reading root `CLAUDE.md`), the Context tab's grouping no longer reflects what actually loads into Claude — the teaching layer silently lies. - **Review trigger:** CC CHANGELOG entries touching `~/.claude/projects/` layout, memory storage/recall, `CLAUDE.md`/`AGENTS.md`/rules discovery, or the slug-encoding scheme. + +### Project-dir slug encoding (Desktop + Android) +- **Files:** `desktop/src/main/slug-encoding.ts` (`ccProjectSlug`), `app/.../runtime/CcProjectSlug.kt` +- **Depends on:** Mirrors CC 2.1.228's `~/.claude/projects//` encoding bug-for-bug — every `[^a-zA-Z0-9]` → `-`, slugs over 200 chars truncated and suffixed with `base36(abs(rolling hash of the ORIGINAL path))`. Recovered from the shipped 2.1.228 binary; anchored to `desktop/tests/fixtures/cc-slug-pairs.json` (harvested + probed real `(cwd → directory)` pairs, `ccVersion: "2.1.229"`) — the rule was recovered from the 2.1.228 binary and the fixtures were independently regenerated against 2.1.229; behavior is identical across both versions. +- **Break symptom:** If CC changes the encoding (character class, the 200-char cap, or the hash function), every touchpoint that derives a `~/.claude/projects//` path from a cwd silently points at a directory CC never writes — chat view, project memory, conversation sync, and resume all go dark for affected projects. See "Project View context discovery" above for the full call-site list on desktop. +- **Review trigger:** CC CHANGELOG entries touching `~/.claude/projects/` layout or the slug-encoding scheme. On a bump, re-run the spec §8 probe; if any fixture pair changes, update both mirrors (`slug-encoding.ts` + `CcProjectSlug.kt`) and the fixture file together. + +### Hook payload `transcript_path`/`cwd` (Desktop + Android) +- **Files:** `desktop/src/main/ipc-handlers.ts` (`hookRelay.on('hook-event')` SessionStart handler), `app/src/main/kotlin/com/youcoded/app/parser/EventBridge.kt`, `app/.../runtime/SyncService.kt` (`pushSession`) +- **Depends on:** `transcript_path` and `cwd` are REQUIRED fields of CC's hook JSON schema (`transcript_path:O(),cwd:O()` in the shipped bundle) and are consumed VERBATIM — no re-derivation. `cwd` is CC's own post-realpath/post-chdir value (the exact string it slugged), so consuming it directly dissolves the symlink hazard the slug mirror would otherwise have to handle. `transcript_path` lets the desktop watcher and Android's `pushSession` skip slug derivation entirely for the life-or-death chat-rendering path (spec §5.0). +- **Break symptom:** If CC drops either field or changes its shape (e.g. relative instead of absolute `transcript_path`), the watcher/store fall back to slug derivation (desktop) or fail silently (Android `pushSession` returns early when the derived path doesn't exist) — chat view goes dark or session-end sync stops uploading, with no error surfaced. +- **Review trigger:** CC CHANGELOG entries touching the hook payload schema, `SessionStart` fields, or `transcript_path`/`cwd` semantics.