From 2d45872d816902a23efd52aae0e33245f30ecf38 Mon Sep 17 00:00:00 2001 From: "Slai.life" Date: Fri, 30 Oct 2026 23:57:17 +0800 Subject: [PATCH] fix(session): handle nested and symlinked archive paths --- src/__tests__/session-sync.test.ts | 75 ++++++++++++++++++++++++++++++ src/session-flow/sync.ts | 75 +++++++++++++++++++++++++----- 2 files changed, 139 insertions(+), 11 deletions(-) diff --git a/src/__tests__/session-sync.test.ts b/src/__tests__/session-sync.test.ts index a23db47a5..71ce6afde 100644 --- a/src/__tests__/session-sync.test.ts +++ b/src/__tests__/session-sync.test.ts @@ -10,6 +10,7 @@ import { encodeRepoIdentity, decodeRepoIdentity, canonicalizeRemote, + archivedCwd, type ArchiveWriteResult, withSessionIndexLock, withSessionPublicationLock, @@ -97,6 +98,80 @@ function readRepoIndex(identity: string): { sessions: Array path.join(repoRoot, 'sessions', '_unattributed'); +describe('archivedCwd', () => { + it('stores a git workspace root as dot and keeps its monorepo subdirectory', async () => { + const workspace = path.join(repoRoot, 'workspace'); + const nested = path.join(workspace, 'packages', 'api'); + fs.mkdirSync(nested, { recursive: true }); + execFileSync('git', ['init', '-q', workspace]); + execFileSync('git', ['-C', workspace, 'config', 'user.email', 'test@example.com']); + execFileSync('git', ['-C', workspace, 'config', 'user.name', 'Test']); + execFileSync('git', ['-C', workspace, 'commit', '--allow-empty', '-qm', 'init']); + + await expect(archivedCwd(workspace)).resolves.toBe('.'); + await expect(archivedCwd(nested)).resolves.toBe('packages/api'); + }); + + it('uses the configured non-git project root and omits paths with no known boundary', async () => { + const project = path.join(repoRoot, 'plain-project'); + const nested = path.join(project, 'backend'); + fs.mkdirSync(nested, { recursive: true }); + + await expect(archivedCwd(nested, project)).resolves.toBe('backend'); + await expect(archivedCwd(path.join(repoRoot, 'scratch'))).resolves.toBe(''); + await expect(archivedCwd('md5:3f2a')).resolves.toBe(''); + }); + + it('keeps a valid child whose name starts with two dots', async () => { + const project = path.join(repoRoot, 'plain-project'); + const nested = path.join(project, '..cache'); + fs.mkdirSync(nested, { recursive: true }); + + await expect(archivedCwd(nested, project)).resolves.toBe('..cache'); + await expect(archivedCwd(path.join(project, '..', 'outside'), project)).resolves.toBe(''); + }); + + it('resolves a symlinked cwd against the real workspace root', async () => { + const project = path.join(repoRoot, 'plain-project'); + const nested = path.join(project, 'packages', 'api'); + const link = path.join(repoRoot, 'workspace-link'); + fs.mkdirSync(nested, { recursive: true }); + try { + fs.symlinkSync(project, link, process.platform === 'win32' ? 'junction' : 'dir'); + } catch (error) { + if (process.platform === 'win32' && (error as NodeJS.ErrnoException).code === 'EPERM') return; + throw error; + } + + await expect(archivedCwd(path.join(link, 'packages', 'api'), project)).resolves.toBe('packages/api'); + }); + + it('stores the normalized cwd in both saved metadata and a rebuilt index', async () => { + const workspace = path.join(repoRoot, 'workspace'); + const nested = path.join(workspace, 'packages', 'api'); + fs.mkdirSync(nested, { recursive: true }); + execFileSync('git', ['init', '-q', workspace]); + execFileSync('git', ['-C', workspace, 'config', 'user.email', 'test@example.com']); + execFileSync('git', ['-C', workspace, 'config', 'user.name', 'Test']); + execFileSync('git', ['-C', workspace, 'commit', '--allow-empty', '-qm', 'init']); + + const identity = 'github.com/org/alpha'; + const mgr = new SyncManager(repoRoot); + const saved = written(await mgr.saveSession( + mkSession({ cwd: nested }), + defaultSyncMeta({ + platform: 'claude-code', author: 'alice', cwd: nested, sessionId: 'relative-cwd', repoIdentity: identity, + }, '2026-01-02T03:04:05.000Z'), + )); + const entry = mgr.listSessions(identity)[0]; + expect(entry.cwd).toBe('packages/api'); + expect(mgr.loadSession(identity, path.basename(saved, '.jsonl'), 'alice').meta.origin.cwd).toBe('packages/api'); + + await mgr.rebuildIndex(identity); + expect(mgr.listSessions(identity)[0].cwd).toBe('packages/api'); + }); +}); + describe('archive save rollback', () => { it.each([false, true].flatMap((existing) => ['index', 'metadata'].map((failure) => ({ existing, failure }))))('restores the previous pair after a $failure rename fails (existing: $existing)', async ({ existing, failure }) => { const mgr = new SyncManager(repoRoot); diff --git a/src/session-flow/sync.ts b/src/session-flow/sync.ts index 67ae9fd4c..af089b3dc 100644 --- a/src/session-flow/sync.ts +++ b/src/session-flow/sync.ts @@ -33,6 +33,7 @@ import { execFileSync } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { getTeamaiHomeDir } from '../types.js'; import { acquireLock, releaseLock } from '../update.js'; +import { resolveAnchors } from '../utils/git.js'; import type { Session } from './ir.js'; import { messageToDict, messageFromDict } from './ir.js'; @@ -44,6 +45,38 @@ function utcNow(): string { return new Date().toISOString(); } +/** + * Return the path stored for a published session instead of the machine's + * absolute path. The archive key still uses the native cwd; this only changes + * the human-readable `origin.cwd` field and the index projection. + * + * Git top-level is preferred because it preserves monorepo subdirectories. + * For a non-git TeamAI project, `projectRoot` is the same stable boundary. + * Encoded/placeholder paths (for example `md5:`) and paths outside a + * known root deliberately become empty rather than leaking a guessed path. + */ +export async function archivedCwd(cwd: string, projectRoot?: string): Promise { + if (cwd.startsWith('md5:') || !path.isAbsolute(cwd)) return ''; + const canonicalCwd = await fs.promises.realpath(cwd).catch(() => path.resolve(cwd)); + const anchors = await resolveAnchors(canonicalCwd); + let config = anchors || projectRoot ? undefined : await import('../config.js') + .then(({ detectProjectConfig }) => detectProjectConfig(canonicalCwd, undefined, { dryRun: true, selfHeal: false })); + if (!anchors && !projectRoot && !config) { + const { detectProjectConfig } = await import('../config.js'); + for (let dir = canonicalCwd; ; dir = path.dirname(dir)) { + config = await detectProjectConfig(dir, undefined, { dryRun: true, selfHeal: false }); + if (config?.projectRoot || path.dirname(dir) === dir) break; + } + } + const root = anchors?.workspaceRoot ?? projectRoot ?? config?.projectRoot; + if (!root) return ''; + const canonicalRoot = await fs.promises.realpath(root).catch(() => path.resolve(root)); + const relative = path.relative(canonicalRoot, canonicalCwd); + if (relative === '') return '.'; + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return ''; + return relative.split(path.sep).join('/'); +} + /** * Use the current git user.name or user.email as the author identifier. * Prefer user.name (more human-friendly), fall back to user.email, then to 'unknown'. @@ -462,8 +495,16 @@ export class SyncManager { } /** Save through the archive lock, including deduplication and index repair. */ - saveSession(session: Session, meta: SessionSyncMeta): Promise> { - return this.writeArchive(() => this.saveSessionLocked(session, meta)); + async saveSession(session: Session, meta: SessionSyncMeta): Promise> { + const archivedCwdValue = await archivedCwd(meta.origin.cwd); + const archivedMeta: SessionSyncMeta = { + ...meta, + origin: { ...meta.origin, cwd: archivedCwdValue }, + }; + return this.writeArchive(() => this.saveSessionLocked( + { ...session, cwd: archivedCwdValue }, + archivedMeta, + )); } rebuildIndex(repoIdentity: string | null, dirOverride?: string): Promise> { @@ -845,6 +886,9 @@ export class SyncManager { const repoId = meta.origin.repoIdentity; const author = meta.origin.author; this.moveRepoDirToCurrentName(repoId); + // `origin.cwd` is converted by callers before entering the archive lock. + // Keep this method free of filesystem-dependent metadata work so save and + // index writes remain a single synchronous transaction. let sessionName = generateSessionName(session.platform, session.title, session.createdAt); // P8 dedup: when the same origin.sessionId + author is pushed again, reuse the original sessionName @@ -1194,16 +1238,25 @@ export class SyncManager { const rank = (e: IndexEntry) => knownAt.get(key(e))?.i ?? known.length; entries.sort((a, b) => rank(a) - rank(b)); - this.writeIndex( + const rebuilt: RepoIndex = { + version: 1, repoIdentity, - { - version: 1, - repoIdentity, - updatedAt: utcNow(), - sessions: entries, - }, - dirOverride, - ); + updatedAt: utcNow(), + sessions: entries, + }; + const indexPath = path.join(dir, '_index.json'); + // An unchanged archive must remain clean after pull. `updatedAt` records + // an index rebuild, not a session change, so retain the committed bytes + // when its session projection is already identical. + try { + const current = JSON.parse(fs.readFileSync(indexPath, 'utf-8')) as RepoIndex; + if (JSON.stringify({ ...current, updatedAt: undefined }) === JSON.stringify({ ...rebuilt, updatedAt: undefined })) { + return entries.length; + } + } catch { + // No readable index yet: write the rebuilt projection below. + } + this.writeIndex(repoIdentity, rebuilt, dirOverride); return entries.length; }