Skip to content
Merged
38 changes: 27 additions & 11 deletions api/checkpoint/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ type WriteOptions struct {
// Branch is the branch name where the checkpoint was created (empty if detached HEAD)
Branch string

// CommitSHA links this checkpoint to an existing commit without a trailer.
// It is an anchor — "imported at this point in time" — not attribution.
// Currently set only by `entire import`: imported history has no
// Entire-Checkpoint trailer (we never rewrite existing commits), so import
// stamps the resolved anchor commit here (the default branch head when
// resolvable; see resolveImportLinkCommitSHA for the fallback order).
// Empty for all other writers. This comment is the canonical description;
// Metadata.CommitSHA and CheckpointSummary.CommitSHA point back here.
CommitSHA string

// Transcript is the session transcript content (full.jsonl).
// Must be pre-redacted (via redact.JSONLBytes or redact.AlreadyRedacted for trusted sources).
Transcript redact.RedactedBytes
Expand Down Expand Up @@ -316,13 +326,17 @@ type SessionContent struct {

// Metadata contains the metadata stored in metadata.json for each checkpoint.
type Metadata struct {
CLIVersion string `json:"cli_version,omitempty"`
CheckpointID id.CheckpointID `json:"checkpoint_id"`
SessionID string `json:"session_id"`
Strategy string `json:"strategy"`
CreatedAt time.Time `json:"created_at"`
Branch string `json:"branch,omitempty"` // Branch where checkpoint was created (empty if detached HEAD)
CheckpointsCount int `json:"checkpoints_count"`
CLIVersion string `json:"cli_version,omitempty"`
CheckpointID id.CheckpointID `json:"checkpoint_id"`
SessionID string `json:"session_id"`
Strategy string `json:"strategy"`
CreatedAt time.Time `json:"created_at"`
Branch string `json:"branch,omitempty"` // Branch where checkpoint was created (empty if detached HEAD)
// CommitSHA anchors an imported checkpoint to an existing commit; empty for
// non-imported checkpoints, which link via the Entire-Checkpoint trailer.
// See WriteOptions.CommitSHA for the full semantics.
CommitSHA string `json:"commit_sha,omitempty"`
CheckpointsCount int `json:"checkpoints_count"`
// SaveStepCount is the number of SaveStep-recorded steps for this session.
// Honest "real checkpoint work happened" signal (0 = commit-only/fallback
// session), kept separate from the displayed CheckpointsCount prompt count.
Expand Down Expand Up @@ -474,10 +488,12 @@ type SessionFilePaths struct {
//
//nolint:revive // Named CheckpointSummary to avoid conflict with existing Summary struct
type CheckpointSummary struct {
CLIVersion string `json:"cli_version,omitempty"`
CheckpointID id.CheckpointID `json:"checkpoint_id"`
Strategy string `json:"strategy"`
Branch string `json:"branch,omitempty"`
CLIVersion string `json:"cli_version,omitempty"`
CheckpointID id.CheckpointID `json:"checkpoint_id"`
Strategy string `json:"strategy"`
Branch string `json:"branch,omitempty"`
// CommitSHA: import-only anchor; see WriteOptions.CommitSHA.
CommitSHA string `json:"commit_sha,omitempty"`
CheckpointsCount int `json:"checkpoints_count"`
FilesTouched []string `json:"files_touched"`
Sessions []SessionFilePaths `json:"sessions"`
Expand Down
30 changes: 28 additions & 2 deletions cmd/entire/cli/agentimport/agentimport.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ type Turn struct {
// subagent's tokens are counted exactly once rather than re-added on every
// turn after it is discovered.
Tokens *types.TokenUsage
// CommitSHAs are the commit SHAs (possibly abbreviated) this turn's
// transcript records creating, in transcript order. Extraction is
// per-importer (see commitSHAsInRange for Claude Code). Callers must
// resolve them against the repo before use. Nil when the transcript
// records no commits (including agents that don't extract them); the
// anchor then falls back to Options.LinkCommitSHA.
CommitSHAs []string
}

// Importer is the per-agent seam: it locates an agent's transcripts for a repo
Expand Down Expand Up @@ -98,6 +105,14 @@ type Options struct {
SessionFilter []string
Now time.Time
DryRun bool

// LinkCommitSHA, when non-empty, is the fallback anchor written to each
// imported checkpoint's metadata as commit_sha — the commit the UI shows
// imported sessions against. The caller resolves it (default branch head
// when resolvable; see resolveImportLinkCommitSHA); Run does not. A turn
// whose transcript records a resolvable commit that is an ancestor of this
// fallback anchors to that real commit instead (see turnAnchorResolver).
LinkCommitSHA string
}

// Result summarizes an import run.
Expand Down Expand Up @@ -136,6 +151,11 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options)
}
}

// One resolver per Run: it lazily walks and memoizes opts.LinkCommitSHA's
// ancestor set the first time a turn actually carries a candidate, so a
// whole import run pays for at most one history walk, not one per turn.
anchorResolver := newTurnAnchorResolver(repo, opts.LinkCommitSHA, opts.Now)

for _, sf := range files {
res.SessionsScanned++
full, readErr := os.ReadFile(sf.Path)
Expand Down Expand Up @@ -170,7 +190,12 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options)
red = r
redacted = true
}
if err := writeTurn(ctx, stores, imp, cid, sf, red, turn); err != nil {
anchor, fromCandidate := anchorResolver.resolve(ctx, turn.CommitSHAs)
if len(turn.CommitSHAs) > 0 && !fromCandidate {
logging.Debug(ctx, "import: turn anchor fell back",
"sessionID", sf.SessionID, "turnUUID", turn.UUID, "candidates", len(turn.CommitSHAs))
}
if err := writeTurn(ctx, stores, imp, cid, sf, red, turn, anchor); err != nil {
return res, err
}
existing[cid.String()] = true
Expand Down Expand Up @@ -261,7 +286,7 @@ func writeSessionState(ctx context.Context, imp Importer, sf SessionFile, turns
return nil
}

func writeTurn(ctx context.Context, stores *cp.Stores, imp Importer, cid id.CheckpointID, sf SessionFile, red redact.RedactedBytes, turn Turn) error {
func writeTurn(ctx context.Context, stores *cp.Stores, imp Importer, cid id.CheckpointID, sf SessionFile, red redact.RedactedBytes, turn Turn, anchorCommitSHA string) error {
if err := stores.Persistent.Write(ctx, cp.Session(cp.WriteOptions{
CheckpointID: cid,
SessionID: sf.SessionID,
Expand All @@ -275,6 +300,7 @@ func writeTurn(ctx context.Context, stores *cp.Stores, imp Importer, cid id.Chec
CheckpointsCount: 1,
CheckpointTranscriptStart: turn.LineStart,
TokenUsage: turn.Tokens,
CommitSHA: anchorCommitSHA,
})); err != nil {
return fmt.Errorf("write imported checkpoint %s: %w", cid, err)
}
Expand Down
137 changes: 136 additions & 1 deletion cmd/entire/cli/agentimport/agentimport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ func initRepoWithCommit(t *testing.T) (*git.Repository, string) {
t.Fatal(err)
}
if _, err := wt.Commit("init", &git.CommitOptions{
Author: &object.Signature{Name: "Test", Email: "test@test.com"},
// When must be a real timestamp: the anchor resolver's bounded walk
// stops at commits older than its date cutoff, and a zero-value When
// (year 1) would halt the walk at the first commit.
Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()},
}); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -152,6 +155,138 @@ func TestRun_ImportsAndIsIdempotent(t *testing.T) {
}
}

// TestRun_StampsLinkCommitSHA proves Options.LinkCommitSHA is copied verbatim
// into each imported checkpoint's commit_sha metadata field, and that leaving
// it unset leaves commit_sha empty. Run resolves nothing itself.
func TestRun_StampsLinkCommitSHA(t *testing.T) {
t.Parallel()
repo, repoDir := initRepoWithCommit(t)
const commitSHA = "b01b59663fd4860fd15a9939499be44a14dbf168"

claudeDirWithSHA := t.TempDir()
writeFixtureSession(t, claudeDirWithSHA, "sess-with-sha.jsonl")
res, err := Run(context.Background(), repo, claudeImporter{}, Options{
RepoRoot: repoDir, OverridePath: claudeDirWithSHA,
Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC),
LinkCommitSHA: commitSHA,
})
if err != nil {
t.Fatal(err)
}
if res.TurnsImported != 2 {
t.Fatalf("want 2 imported, got %+v", res)
}

stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{})
if err != nil {
t.Fatal(err)
}
cid := DeriveCheckpointID("sess-with-sha", "u1")
md, err := stores.Persistent.ReadSessionMetadata(context.Background(), cid, 0)
if err != nil {
t.Fatal(err)
}
if md.CommitSHA != commitSHA {
t.Fatalf("expected commit_sha %q, got %q", commitSHA, md.CommitSHA)
}

// A separate session fixture (own sessionID/turn UUIDs) run with
// LinkCommitSHA unset must persist an empty commit_sha. Reusing the same
// session would be idempotently skipped, so this needs its own fixture.
claudeDirNoSHA := t.TempDir()
writeFixtureSession(t, claudeDirNoSHA, "sess-no-sha.jsonl")
res2, err := Run(context.Background(), repo, claudeImporter{}, Options{
RepoRoot: repoDir, OverridePath: claudeDirNoSHA,
Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatal(err)
}
if res2.TurnsImported != 2 {
t.Fatalf("want 2 imported, got %+v", res2)
}

cid2 := DeriveCheckpointID("sess-no-sha", "u1")
md2, err := stores.Persistent.ReadSessionMetadata(context.Background(), cid2, 0)
if err != nil {
t.Fatal(err)
}
if md2.CommitSHA != "" {
t.Fatalf("expected empty commit_sha, got %q", md2.CommitSHA)
}
}

// TestRun_AnchorsTurnToRecordedCommit proves a turn whose transcript records a
// resolvable, default-branch-reachable commit anchors to that real commit
// instead of the LinkCommitSHA fallback, while a turn with no recorded commit
// still falls back exactly as before.
func TestRun_AnchorsTurnToRecordedCommit(t *testing.T) {
t.Parallel()
repo, repoDir := initRepoWithCommit(t)
firstCommit, err := repo.Head()
if err != nil {
t.Fatal(err)
}
firstSHA := firstCommit.Hash().String()

// Second commit on the default branch, so tip != first commit.
wt, err := repo.Worktree()
if err != nil {
t.Fatal(err)
}
writeAndCommit(t, wt, repoDir, "y", "second")
tipHead, err := repo.Head()
if err != nil {
t.Fatal(err)
}
tipSHA := tipHead.Hash().String()

claudeDir := t.TempDir()
content := strings.Join([]string{
`{"type":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`,
`{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"output_tokens":5}}}`,
`{"type":"user","uuid":"tr1","toolUseResult":{"gitOperation":{"commit":{"sha":"` + firstSHA[:7] + `","kind":"committed"}}}}`,
`{"type":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`,
}, "\n") + "\n"
if err := os.WriteFile(filepath.Join(claudeDir, "sess-anchor.jsonl"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}

res, err := Run(context.Background(), repo, claudeImporter{}, Options{
RepoRoot: repoDir, OverridePath: claudeDir,
Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC),
LinkCommitSHA: tipSHA,
})
if err != nil {
t.Fatal(err)
}
if res.TurnsImported != 2 {
t.Fatalf("want 2 imported, got %+v", res)
}

stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{})
if err != nil {
t.Fatal(err)
}
cid1 := DeriveCheckpointID("sess-anchor", "u1")
md1, err := stores.Persistent.ReadSessionMetadata(context.Background(), cid1, 0)
if err != nil {
t.Fatal(err)
}
if md1.CommitSHA != firstSHA {
t.Fatalf("turn1 CommitSHA = %q, want recorded commit %q", md1.CommitSHA, firstSHA)
}

cid2 := DeriveCheckpointID("sess-anchor", "u2")
md2, err := stores.Persistent.ReadSessionMetadata(context.Background(), cid2, 0)
if err != nil {
t.Fatal(err)
}
if md2.CommitSHA != tipSHA {
t.Fatalf("turn2 CommitSHA = %q, want fallback %q", md2.CommitSHA, tipSHA)
}
}

// TestRun_AppliesConfiguredCustomRedaction proves imported transcripts honor
// repo/user-configured custom_redactions (loaded at the command via
// strategy.EnsureRedactionConfigured), not just always-on secret scanning.
Expand Down
40 changes: 35 additions & 5 deletions cmd/entire/cli/agentimport/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,12 @@ func (claudeImporter) SplitTurns(sf SessionFile, full []byte) ([]Turn, error) {
return nil, nil
}
return &Turn{
UUID: rec.UUID,
Prompt: transcript.ExtractUserContent(rec.Message),
Model: modelInRange(rawLines, start, end),
CreatedAt: parseTimestamp(rec.Timestamp),
Tokens: tokens,
UUID: rec.UUID,
Prompt: transcript.ExtractUserContent(rec.Message),
Model: modelInRange(rawLines, start, end),
CreatedAt: parseTimestamp(rec.Timestamp),
Tokens: tokens,
CommitSHAs: commitSHAsInRange(rawLines, start, end),
}, nil
})
}
Expand Down Expand Up @@ -98,6 +99,35 @@ func modelInRange(rawLines [][]byte, start, end int) string {
return ""
}

// commitSHAsInRange returns the commit SHAs recorded by gitOperation
// tool-result records within [start, end), in order. Only kind "committed"
// is collected — other kinds (or commit-less gitOperation records like
// push/branch/pr) are ignored. SHAs may be abbreviated; resolution happens
// in Run.
func commitSHAsInRange(rawLines [][]byte, start, end int) []string {
var shas []string
for i := start; i < end && i < len(rawLines); i++ {
var rec struct {
ToolUseResult struct {
GitOperation struct {
Commit struct {
SHA string `json:"sha"`
Kind string `json:"kind"`
} `json:"commit"`
} `json:"gitOperation"`
} `json:"toolUseResult"`
}
if err := json.Unmarshal(rawLines[i], &rec); err != nil {
continue
}
c := rec.ToolUseResult.GitOperation.Commit
if c.SHA != "" && c.Kind == "committed" {
shas = append(shas, c.SHA)
}
}
return shas
}

// isUserPromptLine reports whether a raw JSONL line is a genuine user-prompt
// turn start: type "user" (or role "user") with non-empty extractable text.
// tool_result lines are type "user" but carry no text, so they return false.
Expand Down
41 changes: 41 additions & 0 deletions cmd/entire/cli/agentimport/claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,47 @@ func TestClaudeSplitTurns_TwoPromptsBoundedByNext(t *testing.T) {
}
}

// TestClaudeSplitTurns_ExtractsCommitSHAs proves gitOperation tool-result
// records are collected into Turn.CommitSHAs in transcript order, only for
// kind "committed" — a tool_result line carrying a commit is not itself a
// turn boundary (isUserPromptLine already rejects it), so it just attaches to
// the enclosing turn. A garbage (non-JSON) line between the two commit
// records proves one bad line is skipped without dropping the turn's other
// recorded SHAs.
func TestClaudeSplitTurns_ExtractsCommitSHAs(t *testing.T) {
t.Parallel()
full := []byte(strings.Join([]string{
`{"type":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`,
`{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"output_tokens":5}}}`,
`{"type":"user","uuid":"tr1","toolUseResult":{"gitOperation":{"commit":{"sha":"fe71aa6","kind":"committed"},"push":{"ok":true}}}}`,
`not valid json {{{`,
`{"type":"user","uuid":"tr2","toolUseResult":{"gitOperation":{"commit":{"sha":"aabbccd","kind":"committed"}}}}`,
`{"type":"user","uuid":"tr3","toolUseResult":{"gitOperation":{"commit":{"sha":"ddeeff0","kind":"amended"}}}}`,
`{"type":"user","uuid":"tr4","toolUseResult":{"gitOperation":{"push":{"ok":true}}}}`,
`{"type":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`,
}, "\n") + "\n")

turns, err := claudeImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "s.jsonl"), SessionID: "s"}, full)
if err != nil {
t.Fatal(err)
}
if len(turns) != 2 {
t.Fatalf("want 2 turns, got %d", len(turns))
}
wantSHAs := []string{"fe71aa6", "aabbccd"}
if len(turns[0].CommitSHAs) != len(wantSHAs) {
t.Fatalf("turn0 CommitSHAs = %v, want %v", turns[0].CommitSHAs, wantSHAs)
}
for i, want := range wantSHAs {
if turns[0].CommitSHAs[i] != want {
t.Errorf("turn0 CommitSHAs[%d] = %q, want %q", i, turns[0].CommitSHAs[i], want)
}
}
if len(turns[1].CommitSHAs) != 0 {
t.Errorf("turn1 CommitSHAs = %v, want empty", turns[1].CommitSHAs)
}
}

func TestClaudeSplitTurns_ToolResultIsNotATurn(t *testing.T) {
t.Parallel()
full := []byte(strings.Join([]string{
Expand Down
Loading
Loading