| 1 | package checkpoint |
| 2 | |
| 3 | import ( |
| 4 | "time" |
| 5 | |
| 6 | fileenc "reasonix/internal/fileutil/encoding" |
| 7 | ) |
| 8 | |
| 9 | // Schema versions for on-disk checkpoint JSON. |
| 10 | const ( |
| 11 | SchemaV1 = 1 |
| 12 | SchemaV2 = 2 |
| 13 | SchemaV3 = 3 |
| 14 | ) |
| 15 | |
| 16 | // Coverage describes how completely a checkpoint captured workspace mutations. |
| 17 | type Coverage string |
| 18 | |
| 19 | const ( |
| 20 | CoverageComplete Coverage = "complete" |
| 21 | CoveragePartial Coverage = "partial" |
| 22 | CoverageNone Coverage = "none" |
| 23 | CoverageLegacy Coverage = "legacy" |
| 24 | ) |
| 25 | |
| 26 | // CoverageGap records why a checkpoint cannot guarantee full file restore. |
| 27 | type CoverageGap struct { |
| 28 | Reason string `json:"reason"` |
| 29 | Detail string `json:"detail,omitempty"` |
| 30 | Tool string `json:"tool,omitempty"` |
| 31 | Path string `json:"path,omitempty"` |
| 32 | } |
| 33 | |
| 34 | // Common coverage-gap reasons. |
| 35 | const ( |
| 36 | GapBashSideEffect = "bash_side_effect" |
| 37 | GapHookWrite = "hook_write" |
| 38 | GapMCPExternal = "mcp_external" |
| 39 | GapScratch = "scratch" |
| 40 | GapOutsideWorkspace = "outside_workspace" |
| 41 | GapSymlink = "symlink" |
| 42 | GapHardlink = "hardlink" |
| 43 | GapUnreadable = "unreadable" |
| 44 | GapOversized = "oversized" |
| 45 | GapBackgroundWriter = "background_writer_cross_turn" |
| 46 | GapLegacyUnverified = "legacy_unverified" |
| 47 | GapCaptureFailed = "capture_failed" |
| 48 | GapExpiredPayload = "expired_file_payload" |
| 49 | ) |
| 50 | |
| 51 | // HasProjectCoverageGap reports a gap that can prevent restoring workspace |
| 52 | // files. Scratch-only gaps do not. |
| 53 | func HasProjectCoverageGap(gaps []CoverageGap) bool { |
| 54 | for _, gap := range gaps { |
| 55 | if gap.Reason != "" && gap.Reason != GapScratch { |
| 56 | return true |
| 57 | } |
| 58 | } |
| 59 | return false |
| 60 | } |
| 61 | |
| 62 | // CaptureSource identifies how a preimage was obtained. |
| 63 | type CaptureSource string |
| 64 | |
| 65 | const ( |
| 66 | CapturePreviewer CaptureSource = "previewer" |
| 67 | CaptureBeforeMutation CaptureSource = "before_mutation" |
| 68 | CaptureAfterMutation CaptureSource = "after_mutation" |
| 69 | CaptureLegacy CaptureSource = "legacy" |
| 70 | CaptureManual CaptureSource = "manual" |
| 71 | ) |
| 72 | |
| 73 | // FileRevision is the v2 per-file preimage plus last Reasonix-owned after fingerprint. |
| 74 | type FileRevision struct { |
| 75 | Path string `json:"path"` |
| 76 | Existed bool `json:"existed"` |
| 77 | Mode uint32 `json:"mode,omitempty"` |
| 78 | Encoding *fileenc.Kind `json:"encoding,omitempty"` |
| 79 | SHA256 string `json:"sha256,omitempty"` |
| 80 | BlobRef string `json:"blobRef,omitempty"` |
| 81 | CaptureSource CaptureSource `json:"captureSource,omitempty"` |
| 82 | // AfterSHA256 is the fingerprint of the file after the last Reasonix-owned write. |
| 83 | // Empty means "no after fingerprint" (legacy or never observed). |
| 84 | AfterSHA256 string `json:"afterSha256,omitempty"` |
| 85 | AfterExisted *bool `json:"afterExisted,omitempty"` |
| 86 | AfterMode uint32 `json:"afterMode,omitempty"` |
| 87 | // Inline content is only used for in-memory stores without a blob dir, and |
| 88 | // for legacy v1 migration paths. Persisted v2 checkpoints prefer BlobRef. |
| 89 | Content *string `json:"content,omitempty"` |
| 90 | } |
| 91 | |
| 92 | // MutationRecord tracks one observed mutation for ownership and conflict detection. |
| 93 | type MutationRecord struct { |
| 94 | Seq int64 `json:"seq"` |
| 95 | Path string `json:"path"` |
| 96 | Tool string `json:"tool,omitempty"` |
| 97 | Source CaptureSource `json:"source,omitempty"` |
| 98 | WriterID string `json:"writerId,omitempty"` |
| 99 | Turn int `json:"turn"` |
| 100 | BeforeSHA string `json:"beforeSha,omitempty"` |
| 101 | AfterSHA string `json:"afterSha,omitempty"` |
| 102 | Time time.Time `json:"time,omitempty"` |
| 103 | } |
| 104 | |
| 105 | // ActiveWriter describes a background writer that still owns open mutations. |
| 106 | type ActiveWriter struct { |
| 107 | ID string `json:"id"` |
| 108 | Turn int `json:"turn"` |
| 109 | StartedAt time.Time `json:"startedAt,omitempty"` |
| 110 | Kind string `json:"kind,omitempty"` // "background_subagent", ... |
| 111 | } |
| 112 | |
| 113 | // RewindScope selects what a rewind restores. Mirrors control.RewindScope without |
| 114 | // importing control (checkpoint is a lower layer). |
| 115 | type RewindScope int |
| 116 | |
| 117 | const ( |
| 118 | RewindCode RewindScope = iota // files only |
| 119 | RewindConversation // message log only |
| 120 | RewindBoth // both |
| 121 | ) |
| 122 | |
| 123 | // RewindConflict describes a file that cannot be safely restored. |
| 124 | type RewindConflict struct { |
| 125 | Path string `json:"path"` |
| 126 | Reason string `json:"reason"` |
| 127 | CheckpointSHA string `json:"checkpointSha,omitempty"` |
| 128 | LastOwnedSHA string `json:"lastOwnedSha,omitempty"` |
| 129 | CurrentSHA string `json:"currentSha,omitempty"` |
| 130 | CheckpointMode uint32 `json:"checkpointMode,omitempty"` |
| 131 | CurrentMode uint32 `json:"currentMode,omitempty"` |
| 132 | CurrentExisted bool `json:"currentExisted"` |
| 133 | CheckpointExist bool `json:"checkpointExisted"` |
| 134 | } |
| 135 | |
| 136 | // Conflict reason constants. |
| 137 | const ( |
| 138 | ConflictManualEdit = "manual_edit" |
| 139 | ConflictExternalChange = "external_change" |
| 140 | ConflictDeletedRecreate = "deleted_and_recreated" |
| 141 | ConflictTypeChange = "type_change" |
| 142 | ConflictModeChange = "mode_change" |
| 143 | ConflictMissingPayload = "missing_payload" |
| 144 | ConflictPathUnsafe = "path_unsafe" |
| 145 | ConflictBusyWriter = "active_writer" |
| 146 | ConflictStalePlan = "stale_plan" |
| 147 | ConflictBoundaryInvalid = "boundary_invalid" |
| 148 | ConflictCoverageLegacy = "legacy_unverified" |
| 149 | ConflictExpired = "expired_payload" |
| 150 | ) |
| 151 | |
| 152 | // FileStage records per-file progress through a rewind transaction. |
| 153 | type FileStage struct { |
| 154 | Path string `json:"path"` |
| 155 | Phase string `json:"phase"` // precheck|prepare|commit|compensate|done|skipped |
| 156 | Action string `json:"action,omitempty"` // write|delete|restore |
| 157 | Error string `json:"error,omitempty"` |
| 158 | Compensated bool `json:"compensated,omitempty"` |
| 159 | CompError string `json:"compensateError,omitempty"` |
| 160 | } |
| 161 | |
| 162 | // RewindPlan is the structured precheck result returned to the controller/UI. |
| 163 | type RewindPlan struct { |
| 164 | PlanID string `json:"planId"` |
| 165 | Turn int `json:"turn"` |
| 166 | Scope RewindScope `json:"scope"` |
| 167 | Coverage Coverage `json:"coverage"` |
| 168 | CoverageGaps []CoverageGap `json:"coverageGaps,omitempty"` |
| 169 | Legacy bool `json:"legacy,omitempty"` |
| 170 | ExpiredFilePayload bool `json:"expiredFilePayload,omitempty"` |
| 171 | CanFiles bool `json:"canFiles"` |
| 172 | CanConversation bool `json:"canConversation"` |
| 173 | DisabledReason string `json:"disabledReason,omitempty"` |
| 174 | Conflicts []RewindConflict `json:"conflicts,omitempty"` |
| 175 | Files []string `json:"files,omitempty"` |
| 176 | FileCount int `json:"fileCount"` |
| 177 | ActiveWriters []ActiveWriter `json:"activeWriters,omitempty"` |
| 178 | SessionRevision int64 `json:"sessionRevision"` |
| 179 | WorkspaceToken string `json:"workspaceToken,omitempty"` |
| 180 | BoundaryIndex int `json:"boundaryIndex,omitempty"` |
| 181 | HasBoundary bool `json:"hasBoundary"` |
| 182 | CreatedAt time.Time `json:"createdAt"` |
| 183 | ConversationAction string `json:"conversationAction,omitempty"` |
| 184 | // Single-file revert extras. |
| 185 | Path string `json:"path,omitempty"` |
| 186 | ConflictResolution string `json:"conflictResolution,omitempty"` |
| 187 | } |
| 188 | |
| 189 | // RewindResult is returned after commit or undo. |
| 190 | type RewindResult struct { |
| 191 | OK bool `json:"ok"` |
| 192 | TransactionID string `json:"transactionId,omitempty"` |
| 193 | UndoAvailable bool `json:"undoAvailable"` |
| 194 | Written []string `json:"written,omitempty"` |
| 195 | Deleted []string `json:"deleted,omitempty"` |
| 196 | Files []FileStage `json:"files,omitempty"` |
| 197 | ConversationOK bool `json:"conversationOk,omitempty"` |
| 198 | ConversationForked bool `json:"conversationForked,omitempty"` |
| 199 | OperationID string `json:"operationId,omitempty"` |
| 200 | Branch string `json:"branch,omitempty"` |
| 201 | Partial bool `json:"partial,omitempty"` |
| 202 | Error string `json:"error,omitempty"` |
| 203 | Conflicts []RewindConflict `json:"conflicts,omitempty"` |
| 204 | Coverage Coverage `json:"coverage,omitempty"` |
| 205 | CoverageGaps []CoverageGap `json:"coverageGaps,omitempty"` |
| 206 | } |
| 207 | |
| 208 | // ConflictResolution chooses how to handle a single-file conflict on commit. |
| 209 | type ConflictResolution string |
| 210 | |
| 211 | const ( |
| 212 | // ResolveKeepCurrent leaves the on-disk file alone. |
| 213 | ResolveKeepCurrent ConflictResolution = "keep_current" |
| 214 | // ResolveOverwriteCheckpoint force-writes the checkpoint preimage after |
| 215 | // the user explicitly confirmed in the single-file UI. |
| 216 | ResolveOverwriteCheckpoint ConflictResolution = "overwrite_checkpoint" |
| 217 | ) |
| 218 | |
| 219 | // TransactionState is the durable lifecycle of a rewind transaction. |
| 220 | type TransactionState string |
| 221 | |
| 222 | const ( |
| 223 | TxPrepared TransactionState = "prepared" |
| 224 | TxCommitting TransactionState = "committing" |
| 225 | TxCommitted TransactionState = "committed" |
| 226 | TxAborted TransactionState = "aborted" |
| 227 | TxUndone TransactionState = "undone" |
| 228 | ) |
| 229 | |
| 230 | // TransactionTarget is one file's forward/restore payload inside a transaction. |
| 231 | type TransactionTarget struct { |
| 232 | Path string `json:"path"` |
| 233 | AbsPath string `json:"absPath"` |
| 234 | // Restore: what to write (or delete) to reach checkpoint state. |
| 235 | RestoreExisted bool `json:"restoreExisted"` |
| 236 | RestoreMode uint32 `json:"restoreMode,omitempty"` |
| 237 | RestoreSHA string `json:"restoreSha,omitempty"` |
| 238 | RestoreBlob string `json:"restoreBlob,omitempty"` |
| 239 | RestoreInline []byte `json:"restoreInline,omitempty"` |
| 240 | RestoreEncoding *fileenc.Kind `json:"restoreEncoding,omitempty"` |
| 241 | // Forward: current on-disk state at prepare time (for compensate / undo). |
| 242 | ForwardExisted bool `json:"forwardExisted"` |
| 243 | ForwardMode uint32 `json:"forwardMode,omitempty"` |
| 244 | ForwardSHA string `json:"forwardSha,omitempty"` |
| 245 | ForwardBlob string `json:"forwardBlob,omitempty"` |
| 246 | ForwardInline []byte `json:"forwardInline,omitempty"` |
| 247 | // Staging paths are transaction-unique siblings of AbsPath so publish and |
| 248 | // compensation never cross filesystems. |
| 249 | PublishTmp string `json:"publishTmp,omitempty"` |
| 250 | BackupPath string `json:"backupPath,omitempty"` |
| 251 | // Published is a durable "may have published" intent. It is persisted before |
| 252 | // the first rename so crash recovery conservatively inspects this target. |
| 253 | Published bool `json:"published"` |
| 254 | // Action describes the intended commit action. |
| 255 | Action string `json:"action"` // write|delete |
| 256 | } |
| 257 | |
| 258 | // TransactionManifest is the durable description of a rewind/undo transaction. |
| 259 | type TransactionManifest struct { |
| 260 | SchemaVersion int `json:"schemaVersion"` |
| 261 | ID string `json:"id"` |
| 262 | SessionID string `json:"sessionId,omitempty"` |
| 263 | WorkspaceRoot string `json:"workspaceRoot"` |
| 264 | State TransactionState `json:"state"` |
| 265 | Kind string `json:"kind"` // rewind|undo|file_revert |
| 266 | Turn int `json:"turn"` |
| 267 | Scope RewindScope `json:"scope"` |
| 268 | Path string `json:"path,omitempty"` // single-file |
| 269 | CreatedAt time.Time `json:"createdAt"` |
| 270 | UpdatedAt time.Time `json:"updatedAt"` |
| 271 | SessionRevision int64 `json:"sessionRevision"` |
| 272 | WorkspaceToken string `json:"workspaceToken,omitempty"` |
| 273 | Coverage Coverage `json:"coverage,omitempty"` |
| 274 | CoverageGaps []CoverageGap `json:"coverageGaps,omitempty"` |
| 275 | Targets []TransactionTarget `json:"targets,omitempty"` |
| 276 | // ConversationForward holds a JSON-encoded message snapshot when conversation |
| 277 | // is part of the transaction. Opaque to this package so it can stay free of |
| 278 | // provider imports; the controller supplies and applies it. |
| 279 | ConversationForward []byte `json:"conversationForward,omitempty"` |
| 280 | BoundaryIndex int `json:"boundaryIndex,omitempty"` |
| 281 | HasBoundary bool `json:"hasBoundary"` |
| 282 | ConversationAction string `json:"conversationAction,omitempty"` |
| 283 | // TruncateFrom is the checkpoint turn to drop after a successful conversation rewind. |
| 284 | TruncateFrom int `json:"truncateFrom,omitempty"` |
| 285 | // CheckpointTurns holds serialized future checkpoints for undo. |
| 286 | CheckpointBackup []byte `json:"checkpointBackup,omitempty"` |
| 287 | // ParentTransaction is set for undo transactions that reverse a committed rewind. |
| 288 | ParentTransaction string `json:"parentTransaction,omitempty"` |
| 289 | Error string `json:"error,omitempty"` |
| 290 | } |
| 291 | |
| 292 | // Default retention and soft byte budget for file payloads. Both v3 raw |
| 293 | // preimages and legacy blobs use the same budget value in their own stores. |
| 294 | const ( |
| 295 | DefaultRetainCheckpoints = 100 |
| 296 | DefaultBlobQuotaBytes = 1 << 30 // 1 GiB |
| 297 | DefaultMaxFileBytes = 32 << 20 // 32 MiB per file capture |
| 298 | ) |
| 299 |