From 863aaeee8dd22541fbe9c173f12232151322a54e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=BE=E5=B1=B1=E8=B2=B4=E8=87=B3?= Date: Mon, 1 Jun 2026 19:02:43 +0900 Subject: [PATCH] fix(import): scope dedup by source kind, error on missing codex session Two refinements to the import adapters surfaced by an adversarial review of the codex adapter (#27): - Scope re-import dedup to the session source kind. loadExistingByExternalId now indexes only sessions whose source.kind matches the adapter being imported, so a Codex import can no longer dedup against (or, under --force, delete) a Claude-derived session that happens to share an id string. The id namespaces stay separate as more adapters are added. - `basou import codex --session ` now errors when no rollout in the project matches the id, instead of reporting a silent success. This mirrors the Claude path, which fails on a missing --session transcript. Adds tests for both: cross-kind isolation under --force, and the missing --session error. --- packages/cli/src/commands/import.test.ts | 53 ++++++++++++++++++++++++ packages/cli/src/commands/import.ts | 41 +++++++++++++----- 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/import.test.ts b/packages/cli/src/commands/import.test.ts index 9b6d3912..04ab612c 100644 --- a/packages/cli/src/commands/import.test.ts +++ b/packages/cli/src/commands/import.test.ts @@ -461,4 +461,57 @@ describe("basou import codex", () => { ), ).rejects.toThrow("Codex sessions directory not found"); }); + + it("errors when --session matches no rollout in the project", async () => { + const repo = await setupInitedRepo(); + await writeRollout("codex-1", codexActionRollout(repo, "codex-1")); + await expect( + doRunImportCodex({ session: "nope" }, { cwd: repo, codexSessionsDir: getCodexRoot() }), + ).rejects.toThrow("Codex rollout not found for session id in project"); + }); + + it("dedup is scoped by source kind: never touches a Claude session sharing an id", async () => { + const repo = await setupInitedRepo(); + // A Claude-derived session whose external_id collides with a Codex id. + const claudeSid = "ses_01HXABCDEF1234567890ABCDEF"; + const dir = join(basouPaths(repo).sessions, claudeSid); + await mkdir(dir, { recursive: true }); + await writeYamlFile(join(dir, "session.yaml"), { + schema_version: "0.1.0", + session: { + id: claudeSid, + workspace_id: FIXED_WS_ID, + source: { kind: "claude-code-import", version: "0.1.0", external_id: "shared-id" }, + started_at: "2026-05-10T00:00:00.000Z", + status: "imported", + working_directory: ".", + invocation: { command: "claude", args: [], exit_code: null }, + related_files: [], + events_log: "events.jsonl", + label: "claude-code 2026-05-10: 1 command", + }, + }); + await writeRollout("shared-id", codexActionRollout(repo, "shared-id")); + + // --force would delete a same-id session; the Claude one must survive + // because dedup is scoped to source.kind. + await doRunImportCodex( + { all: true, force: true }, + { cwd: repo, codexSessionsDir: getCodexRoot() }, + ); + + const dirs = await listSessionDirs(repo); + expect(dirs).toContain(claudeSid); + expect(dirs).toHaveLength(2); + const kinds = await Promise.all( + dirs.map( + async (d) => + SessionSchema.parse( + await readYamlFile(join(basouPaths(repo).sessions, d, "session.yaml")), + ).session.source.kind, + ), + ); + expect(kinds.filter((k) => k === "claude-code-import")).toHaveLength(1); + expect(kinds.filter((k) => k === "codex-import")).toHaveLength(1); + }); }); diff --git a/packages/cli/src/commands/import.ts b/packages/cli/src/commands/import.ts index 3fc2cc3b..48f3b7a8 100644 --- a/packages/cli/src/commands/import.ts +++ b/packages/cli/src/commands/import.ts @@ -7,7 +7,9 @@ import { assertBasouRootSafe, type BasouPaths, basouPaths, + CLAUDE_IMPORT_SOURCE, type ClaudeTranscriptRecord, + CODEX_IMPORT_SOURCE, type CodexRolloutRecord, claudeTranscriptToImportPayload, codexRolloutToImportPayload, @@ -22,6 +24,7 @@ import { type Session, type SessionImportPayload, SessionImportPayloadSchema, + type SessionSourceKind, } from "@basou/core"; import type { Command } from "commander"; import { isVerbose, renderCliError } from "../lib/error-render.js"; @@ -178,7 +181,7 @@ export async function doRunImportClaudeCode( }; }); - await importDerivedSessions(paths, manifest, options, candidates); + await importDerivedSessions(paths, manifest, options, CLAUDE_IMPORT_SOURCE, candidates); } export async function doRunImportCodex( @@ -201,7 +204,7 @@ export async function doRunImportCodex( }), })); - await importDerivedSessions(paths, manifest, options, candidates); + await importDerivedSessions(paths, manifest, options, CODEX_IMPORT_SOURCE, candidates); } function assertSelector(options: ImportOptions): void { @@ -225,15 +228,18 @@ async function resolveImportTarget( * The vendor-neutral import core: dedup, derive, validate, and write each * candidate, then report. Every `basou import ` funnels its * discovered candidates through here, so dedup / `--force` / `--dry-run` - * semantics stay identical across adapters. + * semantics stay identical across adapters. Dedup is scoped to `sourceKind` + * so one adapter never matches (or, under `--force`, deletes) another + * adapter's session that happens to share an id string. */ async function importDerivedSessions( paths: BasouPaths, manifest: Manifest, options: ImportOptions, + sourceKind: SessionSourceKind, candidates: ReadonlyArray, ): Promise { - const existingByExternalId = await loadExistingByExternalId(paths); + const existingByExternalId = await loadExistingByExternalId(paths, sourceKind); // Session ids imported earlier in THIS run, so two source files that map to // one session id never double-import within a single invocation. const seenThisRun = new Set(); @@ -312,13 +318,19 @@ function encodeProjectDir(projectPath: string): string { /** * Map of source external_id -> Basou session id(s) already present in the - * workspace, so a re-import can skip (default) or, under --force, delete and - * replace the existing session. Recognises both the structured - * `source.external_id` (current imports) and the `claude-code import ` - * label form (sessions imported before external_id existed), so existing - * dogfood imports are matched either way. Unreadable sessions are skipped. + * workspace for the given `sourceKind`, so a re-import can skip (default) or, + * under --force, delete and replace the existing session. Scoping to one + * source kind keeps each adapter's id namespace separate: a Codex import must + * never dedup against, or delete, a Claude-derived session that happens to + * share an id string. Recognises both the structured `source.external_id` + * (current imports) and the `claude-code import ` label form (sessions + * imported before external_id existed), so existing dogfood imports are + * matched either way. Unreadable sessions are skipped. */ -async function loadExistingByExternalId(paths: BasouPaths): Promise> { +async function loadExistingByExternalId( + paths: BasouPaths, + sourceKind: SessionSourceKind, +): Promise> { const byExternalId = new Map(); const add = (externalId: string, sessionId: string): void => { const list = byExternalId.get(externalId); @@ -338,6 +350,7 @@ async function loadExistingByExternalId(paths: BasouPaths): Promise 0) { add(ext, sessionId); @@ -378,7 +391,10 @@ async function selectTranscriptFiles( * tree is walked and each rollout's `session_meta.cwd` is matched against the * project. The exact-match is also the safety boundary: only sessions started * in the requested project are ever imported. `--session` narrows to a single - * rollout by its Codex session id within that project. + * rollout by its Codex session id within that project; a session id that + * matches no rollout in the project is an error, mirroring how the Claude + * path fails on a missing `--session` transcript rather than reporting a + * silent success. */ async function discoverCodexRollouts( sessionsRoot: string, @@ -394,6 +410,9 @@ async function discoverCodexRollouts( if (options.session !== undefined && meta.id !== options.session) continue; matched.push({ file, externalId: meta.id }); } + if (options.session !== undefined && matched.length === 0) { + throw new Error("Codex rollout not found for session id in project"); + } return matched; }