From c8bcf05f7f9e56973b68a37f8e16b5159adbf78f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 14 Oct 2026 22:59:39 +0200 Subject: [PATCH 1/2] fix(session): treat Codex as installed before its first session Codex creates ~/.codex/sessions on its first session, so a member who installed Codex but never ran it failed 'session migrate -t codex' with 'not installed'. Codex is now ready when its root exists or the codex CLI is on PATH; the write already creates sessions/YYYY/MM/DD. --- src/__tests__/session-codex-install.test.ts | 101 ++++++++++++++++++++ src/session-flow/adapters/codex.ts | 8 +- 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/session-codex-install.test.ts 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/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[] { From 36596459bb4386d86a504b82ed1a3f220c180868 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 14 Oct 2026 23:03:26 +0200 Subject: [PATCH 2/2] fix(session): keep _index.json unchanged when session pull finds nothing new Every session pull rebuilt _index.json with a fresh top-level updatedAt, each entry's updatedAt taken from meta pushedAt (push records the session's own updatedAt), and entries in directory order (push appends). The team checkout stayed modified, so the next pull that brought index changes refused to merge. The rebuild now keeps the entry updatedAt and order it cannot derive from disk, and the write is skipped when the content is unchanged. --- src/__tests__/session-sync.test.ts | 47 ++++++++++++++++++++++++++++++ src/session-flow/sync.ts | 28 +++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) 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/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, {