-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstate.go
More file actions
711 lines (598 loc) · 29.4 KB
/
Copy pathstate.go
File metadata and controls
711 lines (598 loc) · 29.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
package session
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/GrayCodeAI/trace/cli/agent"
"github.com/GrayCodeAI/trace/cli/agent/types"
"github.com/GrayCodeAI/trace/cli/checkpoint/id"
"github.com/GrayCodeAI/trace/cli/jsonutil"
"github.com/GrayCodeAI/trace/cli/logging"
"github.com/GrayCodeAI/trace/cli/osroot"
"github.com/GrayCodeAI/trace/cli/validation"
)
const (
// SessionStateDirName is the directory name for session state files within git common dir.
SessionStateDirName = "trace-sessions"
// StaleSessionThreshold is the duration after which an ended session is considered stale
// and will be automatically deleted during load/list operations.
StaleSessionThreshold = 7 * 24 * time.Hour
// StuckActiveThreshold is the duration after which an ACTIVE session with no
// interaction is considered stuck (used by "trace doctor" and "trace status").
StuckActiveThreshold = 1 * time.Hour
// MaxFilesTouched is the maximum number of files tracked in FilesTouched.
// When exceeded, oldest entries are removed to prevent unbounded growth.
MaxFilesTouched = 1000
// MaxPromptAttributions is the maximum number of attributions tracked.
// When exceeded, oldest entries are removed to prevent unbounded growth.
MaxPromptAttributions = 100
// MaxTurnCheckpointIDs is the maximum number of checkpoint IDs tracked per turn.
// When exceeded, oldest entries are removed to prevent unbounded growth.
MaxTurnCheckpointIDs = 500
)
// Kind identifies the purpose of a session. Empty means "normal" (legacy
// sessions + every session that isn't a review). Callers must not rely on
// Kind being set unless they specifically want to branch on it.
//
// Kind is a discriminator — it distinguishes review variants at a per-session
// granularity. The checkpoint-level HasReview flag remains an umbrella that
// any review-kind session should set (so future review kinds like manual
// review can be added without changing summary-shape).
type Kind string
const (
// KindAgentReview tags a session created by `trace review` (agent-driven
// review). Future review kinds (e.g., manual review) should be defined as
// distinct Kind values AND added to Kind.IsReview so the checkpoint's
// HasReview umbrella flag keeps covering them.
KindAgentReview Kind = "agent_review"
// KindAgentInvestigate tags a session created by `trace investigate`.
KindAgentInvestigate Kind = "agent_investigate"
)
// IsReview reports whether this Kind counts as "a review happened" for the
// purpose of CheckpointSummary.HasReview. Extend this when adding new
// review-kind Kind values (e.g. KindManualReview) so the umbrella flag stays
// accurate without string-literal coupling across packages.
func (k Kind) IsReview() bool {
return k == KindAgentReview
}
// IsInvestigation reports whether this Kind counts as an investigation session.
func (k Kind) IsInvestigation() bool {
return k == KindAgentInvestigate
}
// State represents the state of an active session.
// This is stored in .git/trace-sessions/<session-id>.json
type State struct {
// SessionID is the unique session identifier
SessionID string `json:"session_id"`
// CLIVersion is the version of the CLI that created this session
CLIVersion string `json:"cli_version,omitempty"`
// BaseCommit tracks the current shadow branch base. Initially set to HEAD when the
// session starts, but updated on migration (pull/rebase) and after condensation.
// Used for shadow branch naming and checkpoint storage — NOT for attribution.
BaseCommit string `json:"base_commit"`
// AttributionBaseCommit is the commit used as the reference point for attribution calculations.
// Unlike BaseCommit (which tracks the shadow branch and moves with migration), this field
// preserves the original base commit so deferred condensation can correctly calculate
// agent vs human line attribution. Updated only after successful condensation.
AttributionBaseCommit string `json:"attribution_base_commit,omitempty"`
// WorktreePath is the absolute path to the worktree root
WorktreePath string `json:"worktree_path,omitempty"`
// WorktreeID is the internal git worktree identifier (empty for main worktree)
// Derived from .git/worktrees/<name>/, stable across git worktree move
WorktreeID string `json:"worktree_id,omitempty"`
// StartedAt is when the session was started
StartedAt time.Time `json:"started_at"`
// EndedAt is when the session was explicitly closed by the user.
// nil means the session is still active or was not cleanly closed.
EndedAt *time.Time `json:"ended_at,omitempty"`
// Phase is the lifecycle stage of this session (see phase.go).
// Empty means idle (backward compat with pre-state-machine files).
Phase Phase `json:"phase,omitempty"`
// Kind tags the session's purpose. Empty for normal agent sessions;
// set to KindAgentReview when the session was started by `trace review`.
Kind Kind `json:"kind,omitempty"`
// ReviewSkills is the snapshot of configured review skills at session start.
// Preserved so checkpoint metadata records which skills were run. May be
// empty when a review was attached post-hoc and skills were not declared;
// ReviewPrompt is the ground truth in that case.
ReviewSkills []string `json:"review_skills,omitempty"`
// ReviewPrompt is the actual text of the review request — the composed
// prompt sent to the agent (spawn path) or the session's first user
// prompt (attach path). Always populated when Kind is a review kind.
ReviewPrompt string `json:"review_prompt,omitempty"`
// InvestigateRunID is the 12-hex-char ID of the parent investigation run.
InvestigateRunID string `json:"investigate_run_id,omitempty"`
// InvestigateTopic is the human-readable topic for the investigation run.
InvestigateTopic string `json:"investigate_topic,omitempty"`
// TurnID is a unique identifier for the current agent turn.
// Lifecycle:
// - Generated fresh in InitializeSession at each turn start
// - Shared across all checkpoints within the same turn
// - Used to correlate related checkpoints when a turn's work spans multiple commits
// - Persists until the next InitializeSession call generates a new one
TurnID string `json:"turn_id,omitempty"`
// TurnCheckpointIDs tracks all checkpoint IDs condensed during the current turn.
// Lifecycle:
// - Set in PostCommit when a checkpoint is condensed for an ACTIVE session
// - Consumed in HandleTurnEnd to finalize all checkpoints with the full transcript
// - Cleared in HandleTurnEnd after finalization completes
// - Cleared in InitializeSession when a new prompt starts
// - Cleared when session is reset (ResetSession deletes the state file entirely)
TurnCheckpointIDs []string `json:"turn_checkpoint_ids,omitempty"`
// LastInteractionTime is updated on agent-interaction events (TurnStart,
// TurnEnd, SessionStop, Compaction) but NOT on git commit hooks.
// Used for stale session detection in "trace doctor".
LastInteractionTime *time.Time `json:"last_interaction_time,omitempty"`
// StepCount is the number of checkpoints/steps created in this session.
// JSON tag kept as "checkpoint_count" for backward compatibility with existing state files.
StepCount int `json:"checkpoint_count"`
// CheckpointTranscriptStart is the transcript line offset where the current
// checkpoint cycle began. Set to 0 at session start, updated to current
// transcript length after each condensation. Used to scope the transcript
// for checkpoint condensation: "everything since last checkpoint".
CheckpointTranscriptStart int `json:"checkpoint_transcript_start,omitempty"`
// CheckpointTranscriptSize is the byte size of the transcript at last condensation.
// Used for fast "has new content?" checks in PostCommit: compare the git blob size
// against this value without reading the full transcript content.
CheckpointTranscriptSize int64 `json:"checkpoint_transcript_size,omitempty"`
// CompactTranscriptStart is the transcript.jsonl line offset where the current
// checkpoint cycle began. It parallels CheckpointTranscriptStart (full.jsonl)
// and is updated after each condensation.
CompactTranscriptStart int `json:"compact_transcript_start,omitempty"`
// Deprecated: CondensedTranscriptLines is replaced by CheckpointTranscriptStart.
// Kept for backward compatibility with existing state files.
// Use NormalizeAfterLoad() to migrate.
CondensedTranscriptLines int `json:"condensed_transcript_lines,omitempty"`
// UntrackedFilesAtStart tracks files that existed at session start (to preserve during rewind)
UntrackedFilesAtStart []string `json:"untracked_files_at_start,omitempty"`
// FilesTouched tracks files modified/created/deleted during this session
FilesTouched []string `json:"files_touched,omitempty"`
// LastCheckpointID is the checkpoint ID from the most recent condensation.
// Used to restore the Trace-Checkpoint trailer on amend and to identify
// sessions that have been condensed at least once. Cleared on new prompt.
LastCheckpointID id.CheckpointID `json:"last_checkpoint_id,omitempty"`
// LastCheckpointCommitHash is the exact commit SHA that carried
// LastCheckpointID at condensation time. Used by the reconcile path to
// distinguish "reset back to the condensed commit" (same SHA) from
// "cherry-picked / rebased a commit that happens to preserve the trailer"
// (different SHA). Without this guard, a cherry-picked checkpoint would
// falsely fire reconcile and drop the pinned AttributionBaseCommit,
// corrupting attribution math for uncondensed shadow-branch work.
// Empty for legacy state files — reconcile falls back to trailer-only
// matching for backward compatibility.
LastCheckpointCommitHash string `json:"last_checkpoint_commit_hash,omitempty"`
// FullyCondensed indicates this session has been condensed and has no remaining
// carry-forward files. PostCommit skips fully-condensed sessions entirely.
// Set after successful condensation when no files remain for carry-forward
// and the session phase is ENDED. Cleared on session reactivation (ENDED →
// ACTIVE via TurnStart, or ENDED → IDLE via SessionStart) by ActionClearEndedAt.
FullyCondensed bool `json:"fully_condensed,omitempty"`
// DivergenceNoticeShown indicates the prepare-commit-msg warning about
// attribution divergence has been shown. Set when the warning fires,
// cleared when AttributionBaseCommit realigns with BaseCommit (next
// successful condensation). Prevents repeated warnings on every commit.
DivergenceNoticeShown bool `json:"divergence_notice_shown,omitempty"`
// AttachedManually indicates this session was imported via `trace attach` rather
// than being captured by hooks during normal agent execution.
AttachedManually bool `json:"attached_manually,omitempty"`
// Metadata holds user-defined session tags collected from TRACE_TAG_*
// environment variables at session start. Keys are normalized: the
// TRACE_TAG_ prefix is stripped, converted to lowercase, and hyphens are
// replaced with underscores. Values are stored as-is.
// Example: TRACE_TAG_PROJECT=my-app -> metadata["project"]="my-app"
Metadata map[string]string `json:"metadata,omitempty"`
// Annotations holds free-form user comments attached to the session via
// `trace annotate`. Each entry may optionally reference a specific
// checkpoint. Stored in the session state JSON alongside other metadata.
Annotations []Annotation `json:"annotations,omitempty"`
// AgentType identifies the agent that created this session (e.g., "Claude Code", "Gemini CLI", "Cursor")
AgentType types.AgentType `json:"agent_type,omitempty"`
// ModelName is the LLM model used in this session (e.g., "claude-sonnet-4-20250514", "gpt-4o").
// Set from hook data when the agent provides it.
ModelName string `json:"model_name,omitempty"`
// Token usage tracking (accumulated across all checkpoints in this session)
TokenUsage *agent.TokenUsage `json:"token_usage,omitempty"`
// Hook-provided session metrics (for agents like Cursor that report via hooks)
SessionDurationMs int64 `json:"session_duration_ms,omitempty"`
SessionTurnCount int `json:"session_turn_count,omitempty"`
ContextTokens int `json:"context_tokens,omitempty"`
ContextWindowSize int `json:"context_window_size,omitempty"`
// Deprecated: TranscriptLinesAtStart is replaced by CheckpointTranscriptStart.
// Kept for backward compatibility with existing state files.
TranscriptLinesAtStart int `json:"transcript_lines_at_start,omitempty"`
// TranscriptIdentifierAtStart is the last transcript identifier when the session started.
// Used for identifier-based transcript scoping (UUID for Claude, message ID for Gemini).
TranscriptIdentifierAtStart string `json:"transcript_identifier_at_start,omitempty"`
// TranscriptPath is the path to the live transcript file (for mid-session commit detection)
TranscriptPath string `json:"transcript_path,omitempty"`
// LastPrompt is the most recent user prompt for this session (truncated for display).
// Updated on every turn start (UserPromptSubmit). JSON tag kept as "first_prompt"
// for backward compatibility with existing state files.
LastPrompt string `json:"last_prompt,omitempty"`
// PromptAttributions tracks user and agent line changes at each prompt start.
// This enables accurate attribution by capturing user edits between checkpoints.
PromptAttributions []PromptAttribution `json:"prompt_attributions,omitempty"`
// PendingPromptAttribution holds attribution calculated at prompt start (before agent runs).
// This is moved to PromptAttributions when SaveStep is called.
PendingPromptAttribution *PromptAttribution `json:"pending_prompt_attribution,omitempty"`
}
// Annotation is a free-form user comment attached to a session (and optionally
// a specific checkpoint within it) via `trace annotate`. It is persisted in the
// session state JSON.
type Annotation struct {
// Comment is the user-supplied note text.
Comment string `json:"comment"`
// CheckpointID optionally scopes the annotation to a specific checkpoint.
// Empty means the annotation applies to the session as a whole.
CheckpointID string `json:"checkpoint_id,omitempty"`
// CreatedAt is when the annotation was recorded.
CreatedAt time.Time `json:"created_at"`
// Author is the git author who wrote the annotation, when available.
Author string `json:"author,omitempty"`
}
// PromptAttribution captures line-level attribution data at the start of each prompt.
// By recording what changed since the last checkpoint BEFORE the agent works,
// we can accurately separate user edits from agent contributions.
type PromptAttribution struct {
// CheckpointNumber is which checkpoint this was recorded before (1-indexed)
CheckpointNumber int `json:"checkpoint_number"`
// UserLinesAdded is lines added by user since the last checkpoint
UserLinesAdded int `json:"user_lines_added"`
// UserLinesRemoved is lines removed by user since the last checkpoint
UserLinesRemoved int `json:"user_lines_removed"`
// AgentLinesAdded is total agent lines added so far (base → last checkpoint).
// Always 0 for checkpoint 1 since there's no previous checkpoint to measure against.
AgentLinesAdded int `json:"agent_lines_added"`
// AgentLinesRemoved is total agent lines removed so far (base → last checkpoint).
// Always 0 for checkpoint 1 since there's no previous checkpoint to measure against.
AgentLinesRemoved int `json:"agent_lines_removed"`
// UserAddedPerFile tracks per-file user additions for accurate modification tracking.
// This enables distinguishing user self-modifications from agent modifications.
// See docs/architecture/attribution.md for details.
UserAddedPerFile map[string]int `json:"user_added_per_file,omitempty"`
// UserRemovedPerFile tracks per-file user removals for accurate agent deletion attribution.
// Without this, global user removals would be subtracted from agent-file-only removals,
// incorrectly reducing agent deletion credit when users delete lines in non-agent files.
UserRemovedPerFile map[string]int `json:"user_removed_per_file,omitempty"`
}
// NormalizeAfterLoad applies backward-compatible migrations to state loaded from disk.
// Call this after deserializing a State from JSON.
func (s *State) NormalizeAfterLoad(ctx context.Context) {
// Normalize legacy phase values. "active_committed" was removed with the
// 1:1 checkpoint model in favor of the state machine handling commits
// during ACTIVE phase with immediate condensation.
if s.Phase == "active_committed" {
logCtx := logging.WithComponent(ctx, "session")
logging.Info(
logCtx, "migrating legacy active_committed phase to active",
slog.String("session_id", s.SessionID),
)
s.Phase = PhaseActive
}
// Also normalize via PhaseFromString to handle any other legacy/unknown values.
s.Phase = PhaseFromString(string(s.Phase))
// Migrate transcript fields: CheckpointTranscriptStart replaces both
// CondensedTranscriptLines and TranscriptLinesAtStart from older state files.
if s.CheckpointTranscriptStart == 0 {
if s.CondensedTranscriptLines > 0 {
s.CheckpointTranscriptStart = s.CondensedTranscriptLines
} else if s.TranscriptLinesAtStart > 0 {
s.CheckpointTranscriptStart = s.TranscriptLinesAtStart
}
}
// Clear deprecated fields so they aren't re-persisted.
// Note: this is a one-way migration. If the state is re-saved, older CLI versions
// will see 0 for these fields and fall back to scoping from the transcript start.
// This is acceptable since CLI upgrades are monotonic and the worst case is
// redundant transcript content in a condensation, not data loss.
s.CondensedTranscriptLines = 0
s.TranscriptLinesAtStart = 0
// Backfill AttributionBaseCommit for sessions created before this field existed.
// Without this, a mid-turn commit would migrate BaseCommit and the fallback in
// calculateSessionAttributions would use the migrated value, producing zero attribution.
if s.AttributionBaseCommit == "" && s.BaseCommit != "" {
s.AttributionBaseCommit = s.BaseCommit
}
// DivergenceNoticeShown is only meaningful while attribution is actually
// diverged. Self-heal any state file where the flag outlived the divergence
// — otherwise a future legitimate divergence would be silently suppressed.
if s.DivergenceNoticeShown && s.AttributionBaseCommit == s.BaseCommit {
s.DivergenceNoticeShown = false
}
}
// EnforceLimits caps unbounded arrays to prevent state file bloat.
// When limits are exceeded, the oldest entries (those at the front of each
// slice) are discarded, preserving the most recent data.
// Call this after modifying state and before Save.
func (s *State) EnforceLimits() {
// Cap FilesTouched: keep the most recent MaxFilesTouched entries
if len(s.FilesTouched) > MaxFilesTouched {
s.FilesTouched = s.FilesTouched[len(s.FilesTouched)-MaxFilesTouched:]
}
// Cap PromptAttributions: keep the most recent entries
if len(s.PromptAttributions) > MaxPromptAttributions {
s.PromptAttributions = s.PromptAttributions[len(s.PromptAttributions)-MaxPromptAttributions:]
}
// Cap TurnCheckpointIDs: keep the most recent entries
if len(s.TurnCheckpointIDs) > MaxTurnCheckpointIDs {
s.TurnCheckpointIDs = s.TurnCheckpointIDs[len(s.TurnCheckpointIDs)-MaxTurnCheckpointIDs:]
}
}
// RealignAttributionBase sets AttributionBaseCommit to newBase and clears any
// bookkeeping whose meaning depends on attribution being diverged from the
// shadow-branch base. Call this every time a code path intentionally brings
// AttributionBaseCommit back in line with BaseCommit (condensation, reconcile,
// post-commit base advance) so a stale DivergenceNoticeShown cannot suppress
// the next legitimate divergence warning.
func (s *State) RealignAttributionBase(newBase string) {
s.AttributionBaseCommit = newBase
s.DivergenceNoticeShown = false
}
// IsStale returns true when a session hasn't seen interaction for longer than
// StaleSessionThreshold. Falls back to StartedAt when LastInteractionTime is
// nil (sessions created before interaction tracking was added).
// IsStuckActive returns true if the session is in ACTIVE phase but has not had
// any interaction for longer than StuckActiveThreshold. Falls back to StartedAt
// when LastInteractionTime is nil, so brand-new sessions are not falsely flagged.
func (s *State) IsStuckActive() bool {
if !s.Phase.IsActive() {
return false
}
ref := s.LastInteractionTime
if ref == nil {
ref = &s.StartedAt
}
return time.Since(*ref) > StuckActiveThreshold
}
func (s *State) IsStale() bool {
var since time.Duration
if s.LastInteractionTime != nil {
since = time.Since(*s.LastInteractionTime)
} else {
since = time.Since(s.StartedAt)
}
return since > StaleSessionThreshold
}
// StateStore provides low-level operations for managing session state files.
//
// StateStore is a primitive for session state persistence. It is NOT the same as
// the Sessions interface - it only handles state files in .git/trace-sessions/,
// not the full session data which includes checkpoint content.
//
// Use StateStore directly in strategies for performance-critical state operations.
// Use the Sessions interface (when implemented) for high-level session management.
type StateStore struct {
// stateDir is the directory where session state files are stored
stateDir string
}
// NewStateStore creates a new state store.
// Uses the git common dir to store session state (shared across worktrees).
func NewStateStore(ctx context.Context) (*StateStore, error) {
commonDir, err := getGitCommonDir(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get git common dir: %w", err)
}
return &StateStore{
stateDir: filepath.Join(commonDir, SessionStateDirName),
}, nil
}
// NewStateStoreWithDir creates a new state store with a custom directory.
// This is useful for testing.
func NewStateStoreWithDir(stateDir string) *StateStore {
return &StateStore{stateDir: stateDir}
}
// Load loads the session state for the given session ID.
// Returns (nil, nil) when session file doesn't exist or session is stale (not an error condition).
// Stale sessions (ended longer than StaleSessionThreshold ago) are automatically deleted.
func (s *StateStore) Load(ctx context.Context, sessionID string) (*State, error) {
// Validate session ID to prevent path traversal
if err := validation.ValidateSessionID(sessionID); err != nil {
return nil, fmt.Errorf("invalid session ID: %w", err)
}
root, err := os.OpenRoot(s.stateDir)
if os.IsNotExist(err) {
return nil, nil //nolint:nilnil // nil,nil indicates session not found (expected case)
}
if err != nil {
return nil, fmt.Errorf("failed to open session state directory: %w", err)
}
defer root.Close()
fileName := sessionID + ".json"
data, err := osroot.ReadFile(root, fileName)
if os.IsNotExist(err) {
return nil, nil //nolint:nilnil // nil,nil indicates session not found (expected case)
}
if err != nil {
return nil, fmt.Errorf("failed to read session state: %w", err)
}
// Validate JSON before unmarshal to provide clearer error for truncated/corrupt files.
if !json.Valid(data) {
logCtx := logging.WithComponent(ctx, "session")
logging.Warn(
logCtx, "session state file contains invalid JSON (possibly truncated)",
slog.String("session_id", sessionID),
slog.Int("bytes", len(data)),
)
return nil, fmt.Errorf("session state file is invalid JSON (possibly truncated, %d bytes)", len(data))
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("failed to unmarshal session state: %w", err)
}
state.NormalizeAfterLoad(ctx)
if state.IsStale() {
logCtx := logging.WithComponent(ctx, "session")
logging.Debug(
logCtx, "deleting stale session state",
slog.String("session_id", sessionID),
)
_ = s.Clear(ctx, sessionID) //nolint:errcheck // best-effort cleanup of stale session
return nil, nil //nolint:nilnil // stale session treated as not found
}
return &state, nil
}
// Save saves the session state atomically.
// Automatically enforces size limits on unbounded arrays before persisting.
func (s *StateStore) Save(ctx context.Context, state *State) error {
_ = ctx // Reserved for future use
// Enforce size limits to prevent unbounded growth
state.EnforceLimits()
// Validate session ID to prevent path traversal
if err := validation.ValidateSessionID(state.SessionID); err != nil {
return fmt.Errorf("invalid session ID: %w", err)
}
if err := os.MkdirAll(s.stateDir, 0o750); err != nil {
return fmt.Errorf("failed to create session state directory: %w", err)
}
data, err := jsonutil.MarshalIndentWithNewline(state, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal session state: %w", err)
}
// Use os.Root for traversal-resistant write of temp file.
// Rename is not available on os.Root, so we keep using os.Rename.
root, err := os.OpenRoot(s.stateDir)
if err != nil {
return fmt.Errorf("failed to open session state directory: %w", err)
}
defer root.Close()
fileName := state.SessionID + ".json"
tmpFileName := fileName + ".tmp"
if err := osroot.WriteFile(root, tmpFileName, data, 0o600); err != nil {
return fmt.Errorf("failed to write session state: %w", err)
}
// Atomic rename: not available on os.Root, use os.Rename with validated paths.
stateFile := s.stateFilePath(state.SessionID)
tmpFile := stateFile + ".tmp"
if err := os.Rename(tmpFile, stateFile); err != nil {
return fmt.Errorf("failed to rename session state file: %w", err)
}
return nil
}
// Clear removes the session state file for the given session ID.
func (s *StateStore) Clear(ctx context.Context, sessionID string) error {
_ = ctx // Reserved for future use
// Validate session ID to prevent path traversal
if err := validation.ValidateSessionID(sessionID); err != nil {
return fmt.Errorf("invalid session ID: %w", err)
}
// Remove all files for this session (state .json, .model hint, any future hint files).
// filepath.Glob finds matches; os.Root ensures traversal-resistant removal.
matches, _ := filepath.Glob(filepath.Join(s.stateDir, sessionID+".*")) //nolint:errcheck // pattern is always valid
if len(matches) > 0 {
root, rootErr := os.OpenRoot(s.stateDir)
if rootErr != nil {
return fmt.Errorf("failed to open session state directory for cleanup: %w", rootErr)
}
defer root.Close()
for _, f := range matches {
_ = osroot.Remove(root, filepath.Base(f)) //nolint:errcheck // best-effort cleanup
}
}
return nil
}
// RemoveAll removes the trace session state directory.
// This is used during uninstall to completely remove all session state.
func (s *StateStore) RemoveAll() error {
if err := os.RemoveAll(s.stateDir); err != nil {
return fmt.Errorf("failed to remove session state directory: %w", err)
}
return nil
}
// List returns all session states.
func (s *StateStore) List(ctx context.Context) ([]*State, error) {
entries, err := os.ReadDir(s.stateDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read session state directory: %w", err)
}
var states []*State
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
if strings.HasSuffix(entry.Name(), ".tmp") {
continue // Skip temp files
}
sessionID := strings.TrimSuffix(entry.Name(), ".json")
state, err := s.Load(ctx, sessionID)
if err != nil {
continue // Skip corrupted state files
}
if state == nil {
continue // Not found or stale (Load handles cleanup)
}
states = append(states, state)
}
return states, nil
}
// stateFilePath returns the path to a session state file.
func (s *StateStore) stateFilePath(sessionID string) string {
return filepath.Join(s.stateDir, sessionID+".json")
}
// gitCommonDirCache caches the git common dir to avoid repeated subprocess calls.
// Keyed by working directory to handle directory changes (same pattern as paths.WorktreeRoot).
var (
gitCommonDirMu sync.RWMutex
gitCommonDirCache string
gitCommonDirCacheDir string
)
// ClearGitCommonDirCache clears the cached git common dir.
// Useful for testing when changing directories.
func ClearGitCommonDirCache() {
gitCommonDirMu.Lock()
gitCommonDirCache = ""
gitCommonDirCacheDir = ""
gitCommonDirMu.Unlock()
}
// GetGitCommonDir returns the path to the shared git directory.
// In a regular checkout, this is .git/
// In a worktree, this is the main repo's .git/ (not .git/worktrees/<name>/)
func GetGitCommonDir(ctx context.Context) (string, error) {
return getGitCommonDir(ctx)
}
// getGitCommonDir returns the path to the shared git directory.
// In a regular checkout, this is .git/
// In a worktree, this is the main repo's .git/ (not .git/worktrees/<name>/)
// The result is cached per working directory.
func getGitCommonDir(ctx context.Context) (string, error) {
cwd, err := os.Getwd() //nolint:forbidigo // used for cache key, not git-relative paths
if err != nil {
cwd = ""
}
// Check cache with read lock first
gitCommonDirMu.RLock()
if gitCommonDirCache != "" && gitCommonDirCacheDir == cwd {
cached := gitCommonDirCache
gitCommonDirMu.RUnlock()
return cached, nil
}
gitCommonDirMu.RUnlock()
// Cache miss — resolve via git subprocess
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-common-dir")
cmd.Dir = "."
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get git common dir: %w", err)
}
commonDir := strings.TrimSpace(string(output))
// git rev-parse --git-common-dir returns relative paths from the working directory,
// so we need to make it absolute if it isn't already
if !filepath.IsAbs(commonDir) {
commonDir = filepath.Join(".", commonDir)
}
commonDir = filepath.Clean(commonDir)
gitCommonDirMu.Lock()
gitCommonDirCache = commonDir
gitCommonDirCacheDir = cwd
gitCommonDirMu.Unlock()
return commonDir, nil
}