Documentation
¶
Overview ¶
Audit logging for SP-077: tracks every write-back of OriginalCode (or NewCode) to the working tree. Each write-back is a potential source of silent committed-work reversion, so these audit lines include a stack trace to definitively identify the caller.
The logs use a distinctive [SP077-AUDIT] prefix for easy grepping. They are written to the standard logger so they show up in agent debug output without requiring verbose mode.
Package history provides change tracking, revision management, and display.
This package has been split from a single monolithic file (changetracker.go) into three focused files:
- changetracker_record.go — types, revision grouping, and display
- changetracker_revert.go — revert/restore logic and staleness checks
- changetracker_persist.go — persisted constants
All exported API symbols remain unchanged.
Persistence operations for change tracking ¶
Revision recording, grouping, and display ¶
Revision revert and staleness checking ¶
Quantity-based tiered compaction for the persistent revision store.
The revisions / changes directories grow unbounded over a project's lifetime — every Commit() writes a new revision dir + diff payloads. On a heavy project this is hundreds of MB per month and untenable over a year. The ChangeTracker is a short-horizon stop-gap (undo a bad sed -i, recover a hasty rm), not a long-term audit log — git is for that — so the compaction policy is correspondingly simple:
- Hot (most recent HotCount revisions): kept verbatim
- Warm (next WarmCount): conversation.json dropped
- Dropped (everything older): deleted (or archived if ArchiveFrozen is enabled)
Position is by revision-directory mtime (newest first). The view tools (view_history) bump mtime when they access a revision so an old revision the user comes back to floats to the top automatically — the next compaction pass sees it as hot and keeps it (or warm, depending on its new position).
Index ¶
- Constants
- func AuditRevertSkip(caller, path, reason string)
- func AuditRevertWrite(caller, path, contentType string)
- func ClearAll(workspace string) (changesCleared int, revisionsCleared int, err error)
- func ClearOlderThan(workspace string, since time.Time) (changesCleared int, revisionsCleared int, err error)
- func GetChangedFilesSince(since time.Time) ([]string, error)
- func GetChangesDir() string
- func GetDiff(filename, originalCode, newCode string) string
- func GetFilesForRevision(revisionID string) ([]string, error)
- func GetPathsForTesting() (string, string)
- func GetRevisionsDir() string
- func HasActiveChangesForRevision(revisionID string) (bool, error)
- func InitializeHistoryPaths(config *configuration.Config)
- func IsChangeOlderThan(metadataPath string, since time.Time) bool
- func IsRevertSafe(filename, newCode string) bool
- func IsRevertSafeWithOriginal(filename, newCode, originalCode string) bool
- func MarkChangeSuperseded(fileRevisionHash string) error
- func PrintDiff(filename, originalCode, newCode string)
- func PrintRevisionHistory() error
- func PrintRevisionHistoryBuffer() (string, error)
- func PrintRevisionHistoryWithReader(inputReader *bufio.Reader) error
- func RecordBaseRevision(requestHash, instructions, response string, conversation []APIMessage) (string, error)
- func RecordChange(baseRevisionID string, ...) error
- func RecordChangeWithDetails(baseRevisionID string, ...) error
- func RevertChangeByRevisionID(revisionID string) error
- func SetPathsForTesting(cDir, rDir string)
- func TouchRevision(revisionID string) error
- type APIMessage
- type APIToolCall
- type ChangeLog
- type ChangeMetadata
- type CompactionStats
- type RetentionPolicy
- type RevisionGroup
Constants ¶
const ( RedColor = "\x1b[31m" GreenColor = "\x1b[32m" YellowColor = "\x1b[33m" BoldStyle = "\x1b[1m" ResetColor = "\x1b[0m" NumberOfContextLines = 3 // Number of context lines to show around changes )
Color constants for better readability
const RedactedContentMarker = "[REDACTED - external file]"
RedactedContentMarker is the canonical marker used when file content is redacted because the file is outside the workspace root (to avoid leaking sensitive data). It is defined here, in the lower-level history package, so both pkg/history and pkg/agent reference a single source of truth instead of maintaining duplicate copies that can silently drift. pkg/agent references this via history.RedactedContentMarker.
Variables ¶
This section is empty.
Functions ¶
func AuditRevertSkip ¶ added in v0.16.18
func AuditRevertSkip(caller, path, reason string)
AuditRevertSkip logs when a staleness guard refuses a write-back. Useful for correlating how many reverts were blocked vs. how many went through, and confirming the guards are firing.
func AuditRevertWrite ¶ added in v0.16.18
func AuditRevertWrite(caller, path, contentType string)
AuditRevertWrite logs a write-back of tracked content (OriginalCode or NewCode) to the working tree. Called immediately before every os.WriteFile / filesystem.SaveFile in the rollback/recovery paths.
`caller` identifies the function performing the write (e.g. "handleRevisionRollback", "revertOne"). `path` is the absolute or relative filesystem path being written. `contentType` is "OriginalCode" or "NewCode" so the log distinguishes reverts from restores.
The stack trace captures the full call chain — this is the critical piece for diagnosing whether the write was triggered by an LLM tool call, a CLI command, a test, or an unexpected automatic path.
func ClearAll ¶
ClearAll removes all change entries and all revision directories. If workspace is non-empty, it operates on that workspace's .sprout directory. If workspace is empty, it uses the globally configured paths. Returns the number of changes cleared, revisions cleared, and any error.
func ClearOlderThan ¶
func ClearOlderThan(workspace string, since time.Time) (changesCleared int, revisionsCleared int, err error)
ClearOlderThan removes all change entries and revision directories where the change timestamp is strictly before 'since'. If workspace is non-empty, it operates on that workspace's .sprout directory. If workspace is empty, it uses the globally configured paths. Returns the number of changes cleared, revisions cleared, and any error.
func GetChangedFilesSince ¶
GetChangedFilesSince returns a unique list of filenames changed after the given time.
func GetChangesDir ¶
func GetChangesDir() string
GetChangesDir returns the current changes directory path
func GetFilesForRevision ¶
HasActiveChangesForRevision returns whether a revision ID exists and has any active changes GetFilesForRevision returns the file paths of all active changes in a revision. Returns an empty slice if the revision is not found or has no active changes.
func GetPathsForTesting ¶ added in v0.17.7
GetPathsForTesting is the cross-package test hook for reading the current history storage paths. Tests typically pair this with SetPathsForTesting to capture the pre-test values and restore them in t.Cleanup, so a test that redirects storage to a temp dir does not leak that redirect into sibling tests or later runs of the same test in -count=N invocations.
Returns (changesDir, revisionsDir). Safe to call from multiple goroutines.
func GetRevisionsDir ¶
func GetRevisionsDir() string
GetRevisionsDir returns the current revisions directory path
func InitializeHistoryPaths ¶
func InitializeHistoryPaths(config *configuration.Config)
InitializeHistoryPaths configures the history storage paths based on configuration This should be called at application startup to ensure correct path resolution.
SP-133: changes/ and revisions/ are now workspace-local only (under <workspace>/.sprout/). The global "HistoryScope" branch is removed — it created a dual-role directory when the workspace was $HOME, causing the user-level state dir to accumulate per-repo snapshots.
func IsChangeOlderThan ¶
IsChangeOlderThan reads a change's metadata.json and returns true if the change's timestamp is strictly before 'since'. Returns false if the file cannot be read or parsed.
func IsRevertSafe ¶ added in v0.16.18
IsRevertSafe reports whether it is SAFE to proceed with a revert that writes OriginalCode back to disk. It returns true when the revert will NOT clobber intentional work, and false when it would. The decision layers two checks: content-identity and git-awareness.
func IsRevertSafeWithOriginal ¶ added in v0.16.18
IsRevertSafeWithOriginal is the full-aware staleness guard used by recovery paths that have the OriginalCode. The original-aware path allows recovery when the file on disk matches HEAD but the OriginalCode is NOT the HEAD content — meaning the original was uncommitted work.
func MarkChangeSuperseded ¶ added in v0.16.18
MarkChangeSuperseded marks a change record as "superseded" — the change has been committed to version control and is no longer a recoverable agent edit. This is used by the SP-077 sweep in ChangeTracker.Commit() to prevent old snapshots from being reverted after their content has been committed to git HEAD.
func PrintRevisionHistory ¶
func PrintRevisionHistory() error
func PrintRevisionHistoryBuffer ¶
PrintRevisionHistoryBuffer displays the revision history to a buffer for seamless console experience
func PrintRevisionHistoryWithReader ¶
PrintRevisionHistoryWithReader allows custom input reader for interactive navigation
func RecordBaseRevision ¶
func RecordBaseRevision(requestHash, instructions, response string, conversation []APIMessage) (string, error)
RecordBaseRevision saves the initial request and response, returning a revision ID. conversation is the full conversation history (all user/assistant/tool messages)
func RecordChange ¶
func RecordChange(baseRevisionID string, filename, originalCode, newCode, description, note string) error
RecordChange saves a specific file change against a base revision.
func RecordChangeWithDetails ¶
func RecordChangeWithDetails(baseRevisionID string, filename, originalCode, newCode, description, note string, originalPrompt string, llmMessage string, editingModel string) error
RecordChangeWithDetails saves a specific file change against a base revision with additional details.
func RevertChangeByRevisionID ¶
RevertChangeByRevisionID reverts all changes associated with a given revision ID.
func SetPathsForTesting ¶ added in v0.17.7
func SetPathsForTesting(cDir, rDir string)
SetPathsForTesting is the cross-package test hook for redirecting the history storage to a temporary directory. Callers (typically tests in pkg/agent and other consumers) should set both SPROUT_CONFIG (via configuration.NewTestManager) AND call this function with a fresh t.TempDir()-derived path — NewTestManager alone is insufficient because HistoryScope="project" (the default) resolves changesDir and revisionsDir to relative paths under the process CWD, not the test's temp config dir. Without this hook, every test asserting exact change counts (e.g. TestChangeTrackingE2E's "len(allChanges) == 1") reads from the shared .sprout/changes/ in the repo root and fails on runs where prior tests or sessions have left residue.
Designed for t.Cleanup use:
tmp := t.TempDir()
history.SetPathsForTesting(filepath.Join(tmp, "changes"), filepath.Join(tmp, "revisions"))
t.Cleanup(func() { history.SetPathsForTesting(originalChanges, originalRevisions) })
Reads current values via GetPathsForTesting when restoring.
Safe to call from multiple goroutines; takes the same package-level pathMu that the production path resolvers use.
func TouchRevision ¶
TouchRevision bumps the revision directory's mtime to now. Called when view_history accesses a revision so the next compaction pass considers it "recently used" and keeps it (or re-promotes it from warm back toward hot) regardless of its position in raw creation order. No-op if the revision dir doesn't exist (already dropped).
Types ¶
type APIMessage ¶
type APIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
// ReasoningDetails persists structured reasoning blocks (OpenRouter
// unified reasoning_details array). In-memory these travel on
// api.Message.Meta; the JSON field is the durable form for session files.
ReasoningDetails []map[string]interface{} `json:"reasoning_details,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls []APIToolCall `json:"tool_calls,omitempty"`
}
APIMessage represents a message in the conversation (imported from agent_api to avoid circular dependency)
type APIToolCall ¶
type APIToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
APIToolCall represents a tool call in a message
type ChangeLog ¶
type ChangeLog struct {
RequestHash string
Instructions string
Response string
FileRevisionHash string
Filename string
OriginalCode string
NewCode string
Description string
Note sql.NullString
Status string
Timestamp time.Time
OriginalPrompt string // Added: Original user prompt
LLMMessage string // Added: Full message sent to LLM
AgentModel string // Added: Editing model used
HasConversation bool // Added: Whether conversation.json exists for this revision
// Tier reflects the revision's compaction state: "hot" (full data
// including conversation.json) or "warm" (conversation.json
// dropped). Empty string is treated as hot for backward compat.
Tier string
}
ChangeLog represents a logged change, including context from the base revision.
func GetAllChanges ¶
GetAllChanges returns all recorded changes (most recent first).
func GetAllChangesMetadata ¶ added in v0.16.12
GetAllChangesMetadata returns change metadata WITHOUT reading or base64-decoding the .original/.updated content files. This is the lightweight alternative to GetAllChanges for callers that only need the manifest fields (filename, revision, timestamp, status, tier) — primarily list_changes when include_diff/show_content aren't set.
The OriginalCode and NewCode fields of the returned ChangeLog entries are left EMPTY. Callers that infer op/recoverability from content presence should instead use HasOriginal/HasNew, which report whether the content files exist on disk (a cheap os.Stat, not a read+decode). This avoids the O(total-history) base64 decode that fetchAllChanges performs on every list_changes invocation.
type ChangeMetadata ¶
type ChangeMetadata struct {
Version int `json:"version"`
Filename string `json:"filename"`
FileRevisionHash string `json:"file_revision_hash"`
RequestHash string `json:"request_hash"` // This is the revision ID
Timestamp time.Time `json:"timestamp"`
Status string `json:"status"`
Note string `json:"note"`
Description string `json:"description"`
OriginalPrompt string `json:"original_prompt,omitempty"` // Added: Original user prompt
LLMMessage string `json:"llm_message,omitempty"` // Added: Full message sent to LLM
AgentModel string `json:"agent_model,omitempty"` // Added: Editing model used
}
ChangeMetadata stores metadata about a specific file change.
type CompactionStats ¶
type CompactionStats struct {
TotalRevisions int
HotKept int
WarmDemoted int // revisions moved hot→warm or already warm
Dropped int // revisions moved out of warm → deleted/archived
ChangesPayloadsDeleted int
BytesReclaimed int64
HardCapTrimmed int
OrphanChangesDropped int
OverCapChangesDropped int
AgedChangesDropped int
}
CompactionStats reports what a single CompactRevisions pass did. Useful for logs / metrics; not consumed by anything load-bearing.
func CompactRevisions ¶
func CompactRevisions(policy RetentionPolicy) (CompactionStats, error)
CompactRevisions runs one compaction pass over the configured revisions directory according to the given policy. Safe to call concurrently from multiple agents (mutex-serialized). Idempotent: repeated calls are no-ops once revisions are in their target tier.
Returns stats for logging; errors are non-fatal at the call site (caller should log and continue — a failed compaction just means disk usage stays where it was, nothing breaks).
type RetentionPolicy ¶
type RetentionPolicy struct {
HotCount int
WarmCount int
MaxDirBytes int64
ArchiveFrozen bool
// MaxChangesPerRevision caps the per-revision change-record count
// in the changes/ directory. A single runaway session can produce
// tens of thousands of records (e.g. when the agent `cd`s into
// $HOME and a shell walk misclassifies pre-existing files as
// creates). Without this cap, count-based bloat persists even
// when total bytes are under MaxDirBytes. Zero disables.
MaxChangesPerRevision int
// MaxChangesAge drops change records older than this regardless of
// their parent revision's tier. Belt-and-suspenders against
// changes/ growing unbounded inside the hot window. Zero disables.
MaxChangesAge time.Duration
}
RetentionPolicy is the subset of RevisionRetentionConfig the compactor needs. Kept separate to avoid a cycle with pkg/configuration.
type RevisionGroup ¶
type RevisionGroup struct {
RevisionID string
Instructions string
Response string
Changes []ChangeLog
Timestamp time.Time
AgentModel string // Editing model used for this revision
Conversation []APIMessage // Full conversation history for multi-turn conversations
}
RevisionGroup represents a group of changes that belong to the same revision
func GetRevisionGroups ¶
func GetRevisionGroups() ([]RevisionGroup, error)
GetRevisionGroups returns all revision groups sorted by timestamp (most recent first)