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
101 changes: 101 additions & 0 deletions src/__tests__/session-codex-install.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> & { default?: object };
const patched: Record<string, unknown> = { ...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<string> {
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([]);
});
});
47 changes: 47 additions & 0 deletions src/__tests__/session-sync.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/session-flow/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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[] {
Expand Down
28 changes: 27 additions & 1 deletion src/session-flow/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
{
Expand Down
Loading