diff --git a/src/__tests__/session-codex-install.test.ts b/src/__tests__/session-codex-install.test.ts new file mode 100644 index 000000000..0a9ea7bd0 --- /dev/null +++ b/src/__tests__/session-codex-install.test.ts @@ -0,0 +1,101 @@ +/** + * Codex counts as installed before its first session: `~/.codex/sessions/` only + * appears once Codex has run a session, so a member who installed Codex but + * never used it must still be a valid migrate target. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const mocks = vi.hoisted(() => ({ home: '' })); + +vi.mock('node:os', async (importOriginal) => { + const actual = (await importOriginal()) as unknown as Record & { default?: object }; + const patched: Record = { ...actual, homedir: () => mocks.home }; + patched.default = { ...actual.default, homedir: () => mocks.home }; + return patched; +}); + +import { getAdapter, listInstalledPlatforms } from '../session-flow/adapters/index.js'; +import { MigrationEngine } from '../session-flow/migrate.js'; +import type { Session } from '../session-flow/ir.js'; + +const SESSION_ID = '11111111-2222-4333-8444-555555555555'; + +let tmp = ''; +let emptyBin = ''; +const savedPath = process.env.PATH; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-install-')); + mocks.home = path.join(tmp, 'home'); + fs.mkdirSync(mocks.home); + emptyBin = path.join(tmp, 'empty-bin'); + fs.mkdirSync(emptyBin); + process.env.PATH = emptyBin; +}); + +afterEach(() => { + process.env.PATH = savedPath; + mocks.home = ''; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +/** Plants a Claude Code session for `cwd` and returns its id. */ +async function plantClaudeSession(cwd: string): Promise { + fs.mkdirSync(path.join(mocks.home, '.claude', 'projects'), { recursive: true }); + const session: Session = { + sessionId: SESSION_ID, + title: 'hello codex', + cwd, + platform: 'claude-code', + createdAt: '2026-09-20T10:00:00.000Z', + updatedAt: '2026-09-20T10:01:00.000Z', + messages: [ + { role: 'user', content: [{ type: 'text', text: 'hello codex' }], timestamp: '2026-09-20T10:00:00.000Z' }, + { role: 'assistant', content: [{ type: 'text', text: 'hi' }], timestamp: '2026-09-20T10:00:30.000Z' }, + ], + metadata: {}, + }; + return getAdapter('claude-code').writeSession(session, cwd); +} + +describe('Codex installed without a sessions directory', () => { + it('migrates into Codex when only ~/.codex exists, creating the dated sessions dir', async () => { + const cwd = path.join(tmp, 'proj'); + fs.mkdirSync(cwd); + const sourceId = await plantClaudeSession(cwd); + fs.mkdirSync(path.join(mocks.home, '.codex')); + + const result = await new MigrationEngine('claude-code', 'codex').migrate(sourceId, cwd); + + expect(result.error).toBeUndefined(); + expect(result.success).toBe(true); + const dayDir = path.join(mocks.home, '.codex', 'sessions', '2026', '09', '20'); + const rollouts = fs.readdirSync(dayDir).filter((f) => f.endsWith('.jsonl')); + expect(rollouts).toHaveLength(1); + expect(rollouts[0]).toContain(result.targetSessionId); + }); + + it('lists Codex as installed when the codex CLI is on PATH and ~/.codex does not exist yet', () => { + const bin = path.join(tmp, 'bin'); + fs.mkdirSync(bin); + const exe = path.join(bin, 'codex'); + fs.writeFileSync(exe, '#!/bin/sh\n'); + fs.chmodSync(exe, 0o755); + process.env.PATH = bin; + + expect(listInstalledPlatforms()).toContain('codex'); + }); + + it('does not list Codex when neither ~/.codex nor the codex CLI is present', () => { + expect(listInstalledPlatforms()).not.toContain('codex'); + }); + + it('lists no Codex sessions, without failing, when ~/.codex has no sessions dir', async () => { + fs.mkdirSync(path.join(mocks.home, '.codex')); + + await expect(getAdapter('codex').listConversations()).resolves.toEqual([]); + }); +}); diff --git a/src/__tests__/session-sync.test.ts b/src/__tests__/session-sync.test.ts index a51359725..6f16e91cb 100644 --- a/src/__tests__/session-sync.test.ts +++ b/src/__tests__/session-sync.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -392,6 +393,52 @@ describe('rebuildIndex', () => { }); }); +describe('session pull (gitPull + rebuildIndex) on a team checkout', () => { + const identity = 'github.com/org/alpha'; + const git = (cwd: string, ...args: string[]): string => + execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + + function initCheckout(dir: string, name: string): void { + git(dir, 'config', 'user.name', name); + git(dir, 'config', 'user.email', `${name}@example.com`); + } + + function pushSession(checkout: string, sessionId: string, title: string): void { + const mgr = new SyncManager(checkout); + mgr.saveSession(mkSession({ sessionId, title }), mkMeta({ sessionId, title })); + expect(mgr.gitCommit(`push ${title}`).status).toBe('committed'); + mgr.gitPush(); + } + + function pull(checkout: string): void { + const mgr = new SyncManager(checkout); + mgr.gitPull(); + mgr.rebuildIndex(identity); + } + + it('leaves the checkout clean when nothing changed, so the next index change merges', () => { + const remote = path.join(repoRoot, 'team.git'); + git(repoRoot, 'init', '-q', '--bare', '-b', 'main', remote); + const alice = path.join(repoRoot, 'alice'); + git(repoRoot, 'init', '-q', '-b', 'main', alice); + initCheckout(alice, 'alice'); + git(alice, 'remote', 'add', 'origin', remote); + pushSession(alice, 's-1', 'fix payment'); + const bob = path.join(repoRoot, 'bob'); + git(repoRoot, 'clone', '-q', remote, bob); + initCheckout(bob, 'bob'); + + pull(bob); + pull(bob); + expect(git(bob, 'status', '--porcelain')).toBe(''); + + pushSession(alice, 's-2', 'add refunds'); + pull(bob); + expect(git(bob, 'status', '--porcelain')).toBe(''); + expect(new SyncManager(bob).listSessions(identity).map((s) => s.sessionId).sort()).toEqual(['s-1', 's-2']); + }); +}); + describe('canonicalizeRemote', () => { it('strips credentials embedded in the remote URL', () => { // Tokens live in remotes on CI checkouts; publishing them into the diff --git a/src/session-flow/adapters/codex.ts b/src/session-flow/adapters/codex.ts index 0c120ab27..694c7844e 100644 --- a/src/session-flow/adapters/codex.ts +++ b/src/session-flow/adapters/codex.ts @@ -47,6 +47,7 @@ import { titleFromUserText, visibleUserText } from '../title.js'; import { assertSafeSessionFileId, resolveWriteSessionId } from '../ids.js'; import { findSqlite3 } from '../sqlite.js'; import { log } from '../../utils/logger.js'; +import { isOnPath } from '../../utils/lookpath.js'; import { getCodexSessionsDir, resolveRealCwd, @@ -276,8 +277,13 @@ export class CodexAdapter extends AgentAdapter { return getCodexSessionsDir(); } + /** + * Codex creates `sessions/` on its first session, so an installed Codex that + * has not run one yet only has its root (or just the CLI); writeSession + * creates the dated directory itself. + */ isReady(): boolean { - return dirExists(this.storageRoot); + return dirExists(path.dirname(this.storageRoot)) || (this.platform === 'codex' && isOnPath('codex')); } private scanJsonlFiles(): string[] { diff --git a/src/session-flow/sync.ts b/src/session-flow/sync.ts index a9264da09..d4dff78ff 100644 --- a/src/session-flow/sync.ts +++ b/src/session-flow/sync.ts @@ -441,8 +441,18 @@ export class SyncManager { // then be checking a path that already exists on the wrong side. this.guardWrite(path.join(dir, '_index.json')); fs.mkdirSync(dir, { recursive: true }); - index.updatedAt = utcNow(); const target = path.join(dir, '_index.json'); + // Same sessions → leave the file byte-identical. A fresh updatedAt on every + // `session pull` kept the team checkout modified, and the next pull that + // brought index changes then refused to merge. + try { + const previous = fs.readFileSync(target, 'utf-8'); + const updatedAt = (JSON.parse(previous) as RepoIndex).updatedAt; + if (JSON.stringify({ ...index, updatedAt }, null, 2) === previous) return; + } catch { + // No index yet, or a corrupt one: write it. + } + index.updatedAt = utcNow(); // Atomic write: land a temp file, then rename. A direct writeFileSync lets two concurrent pushes // truncate each other and leave a partial (or empty) _index.json, which makes every session in the repo // look like it vanished. rename is atomic on the same filesystem, so readers see either the old value or the new one. @@ -886,6 +896,22 @@ export class SyncManager { } } + // Push records the session's own updatedAt, which meta does not keep, and + // appends in push order. Keep both from the current index so rebuilding an + // unchanged archive reproduces the committed index instead of modifying it. + const key = (e: IndexEntry) => `${e.sessionName}:${e.author}`; + let known: IndexEntry[] = []; + try { + const sessions = (JSON.parse(fs.readFileSync(path.join(dir, '_index.json'), 'utf-8')) as RepoIndex).sessions; + if (Array.isArray(sessions)) known = sessions; + } catch { + // No index yet, or a corrupt one: rebuild from disk alone. + } + const knownAt = new Map(known.map((e, i) => [key(e), { i, updatedAt: e.updatedAt }])); + for (const e of entries) e.updatedAt = knownAt.get(key(e))?.updatedAt ?? e.updatedAt; + const rank = (e: IndexEntry) => knownAt.get(key(e))?.i ?? known.length; + entries.sort((a, b) => rank(a) - rank(b)); + this.writeIndex( repoIdentity, {