Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/__tests__/session-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
encodeRepoIdentity,
decodeRepoIdentity,
canonicalizeRemote,
archivedCwd,
type ArchiveWriteResult,
withSessionIndexLock,
withSessionPublicationLock,
Expand Down Expand Up @@ -97,6 +98,80 @@ function readRepoIndex(identity: string): { sessions: Array<Record<string, unkno

const unattrDir = () => 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);
Expand Down
75 changes: 64 additions & 11 deletions src/session-flow/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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:<hash>`) and paths outside a
* known root deliberately become empty rather than leaking a guessed path.
*/
export async function archivedCwd(cwd: string, projectRoot?: string): Promise<string> {
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'.
Expand Down Expand Up @@ -462,8 +495,16 @@ export class SyncManager {
}

/** Save through the archive lock, including deduplication and index repair. */
saveSession(session: Session, meta: SessionSyncMeta): Promise<ArchiveWriteResult<string>> {
return this.writeArchive(() => this.saveSessionLocked(session, meta));
async saveSession(session: Session, meta: SessionSyncMeta): Promise<ArchiveWriteResult<string>> {
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<ArchiveWriteResult<number>> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
Loading