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