Repository navigation
Expand file tree
/
Copy pathcopy.go
More file actions
318 lines (301 loc) · 9.71 KB
/
Copy pathcopy.go
File metadata and controls
318 lines (301 loc) · 9.71 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
package cell
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/Agent-Field/codeaf/internal/filelock"
"github.com/Agent-Field/codeaf/internal/upgradenotice"
)
// copyCheckpoint is a process-death seam; production never pauses construction.
var copyCheckpoint = func(string) {}
// CopyReadyPath marks a legacy copy whose initial turn is durable on this
// computer; snapshots never carry it because it is local bookkeeping.
const CopyReadyPath = StateDir + "/copy-ready"
// CopyOfferedPath is local bookkeeping inside the harness-owned state area.
const CopyOfferedPath = StateDir + "/copy-offered"
// MovingDir is the folder beside the older chats that holds their copies.
const MovingDir = ".moving"
// CopyRoot gives an older conversation one stable destination outside the
// folders released builds scan. Its identity never depends on a retry (C29.6).
func CopyRoot(original string) string {
sum := sha256.Sum256([]byte(filepath.Clean(original)))
var raw [16]byte
copy(raw[:], sum[:16])
return filepath.Join(filepath.Dir(original), MovingDir, encode128(raw))
}
// CopyFolderReadme is the note in the folder that holds moved chats. The
// folder's name reads like a transfer in progress, and removing it by hand
// would delete those chats, so the folder says what it is (#2072). The name is
// kept: older builds and copies already made look for it.
const CopyFolderReadme = "README.txt"
const copyFolderNote = `This folder holds your codeaf chats. They are the live copies codeaf writes to,
not a move in progress. Do not delete it or anything in it: that deletes those
chats. The older originals beside it stay untouched for older codeaf builds.
`
// explainCopyFolder writes the note once; an existing note is left as it is.
func explainCopyFolder(dir string) error {
f, err := os.OpenFile(filepath.Join(dir, CopyFolderReadme), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if errors.Is(err, fs.ErrExist) {
return nil
}
if err != nil {
return err
}
_, err = f.WriteString(copyFolderNote)
return errors.Join(err, f.Close())
}
// MayHoldHistory is the cautious reading of hasSealedTurns, for a caller about
// to delete root rather than reuse it: any turn bytes or adopted record, however
// they parse, and anything it cannot read, counts as history.
func MayHoldHistory(root string) bool {
for _, name := range []string{"turns.jsonl", "adopted.json"} {
info, err := os.Lstat(filepath.Join(root, StateDir, name))
if err == nil && (name == "adopted.json" || info.Size() > 0) {
return true
}
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return true
}
}
return false
}
// Copied resolves only a fully prepared, durable copy. A crash before the
// completion marker leaves the original as the only visible conversation.
func Copied(original string) string {
root := CopyRoot(original)
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(CopyReadyPath))); err == nil {
if _, err := OpenAt(root, filepath.Base(root)); err == nil {
return root
}
}
return original
}
// StampCopyReady records on disk that root is a completed `.moving` copy on
// this computer so Home and legacy-open paths resolve it instead of the
// untouched original.
func StampCopyReady(root string) error {
p := filepath.Join(root, filepath.FromSlash(CopyReadyPath))
if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil {
return err
}
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return err
}
return errors.Join(f.Sync(), f.Close())
}
// PreserveCopyReady keeps this computer's original-copy baseline across a tree restore.
// Snapshots omit local markers, so recreating only their names loses the older-turn notice.
func PreserveCopyReady(root string) (func() error, error) {
path := filepath.Join(root, filepath.FromSlash(CopyReadyPath))
raw, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
keep := err == nil
if !keep && filepath.Base(filepath.Dir(root)) == ".moving" {
_, lockErr := os.Stat(root + ".lock")
keep = lockErr == nil
}
return func() error {
if !keep {
return nil
}
if len(raw) > 0 {
return upgradenotice.Write(path, raw)
}
return StampCopyReady(root)
}, nil
}
// hasSealedTurns reports whether root already holds a cell with at least one
// sealed turn or adopted remote head. Engine.Seal appends to turns.jsonl only
// after composing meta.json and capturing the snapshot, and Materialize writes
// adopted.json only after restoring an incoming head, so either file proves
// root holds real conversation history rather than a half-copied scratch tree.
func hasSealedTurns(root string) bool {
if raw, err := os.ReadFile(filepath.Join(root, StateDir, "turns.jsonl")); err == nil {
sc := bufio.NewScanner(bytes.NewReader(raw))
for sc.Scan() {
line := bytes.TrimSpace(sc.Bytes())
if len(line) == 0 {
continue
}
var turn struct {
ID string `json:"id"`
}
if json.Unmarshal(line, &turn) == nil && strings.TrimSpace(turn.ID) != "" {
return true
}
}
}
if raw, err := os.ReadFile(filepath.Join(root, StateDir, "adopted.json")); err == nil {
var adopted struct {
Head string `json:"head"`
}
if json.Unmarshal(raw, &adopted) == nil && strings.TrimSpace(adopted.Head) != "" {
return true
}
}
return false
}
// CopyLegacy makes a private byte copy and invokes prepare before publishing
// it. It never links files to the original: later writes must stay independent.
// The lock lives outside the original and serializes windows and crash retries.
func CopyLegacy(ctx context.Context, original string, prepare func(context.Context, Cell) error) (Cell, error) {
original = filepath.Clean(original)
info, err := os.Lstat(original)
if err != nil {
return Cell{}, err
}
if !info.IsDir() {
return Cell{}, errors.New("older chat folder is not a directory")
}
root := CopyRoot(original)
if err := os.MkdirAll(filepath.Dir(root), 0700); err != nil {
return Cell{}, err
}
if err := explainCopyFolder(filepath.Dir(root)); err != nil {
return Cell{}, err
}
lock, err := os.OpenFile(root+".lock", os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return Cell{}, err
}
defer lock.Close()
if err := filelock.Lock(lock, true, true); err != nil {
return Cell{}, err
}
if Copied(original) != original {
return OpenAt(root, filepath.Base(root))
}
// Never wipe a destination that already holds sealed turns even if an
// earlier restore or take-back lost its copy-ready marker: reopen and
// re-stamp it when its cell record is valid, or fail loudly so newer turns
// cannot be destroyed.
if hasSealedTurns(root) {
c, openErr := OpenAt(root, filepath.Base(root))
if openErr != nil {
return Cell{}, fmt.Errorf("copy destination %s already holds sealed turns: %w", root, openErr)
}
if err := StampCopyReady(root); err != nil {
return Cell{}, err
}
return c, nil
}
if err := ctx.Err(); err != nil {
return Cell{}, err
}
if _, err := os.Stat(filepath.Join(original, legacyTranscript)); err != nil {
return Cell{}, err
}
if isCell(original) {
return Cell{}, errors.New("this chat already moves")
}
if err := os.RemoveAll(root); err != nil {
return Cell{}, err
}
source, err := os.Open(filepath.Join(original, legacyTranscript))
if err != nil {
return Cell{}, err
}
defer source.Close()
if err := filelock.Lock(source, true, true); err != nil {
return Cell{}, err
}
copyCheckpoint("source-locked")
if err := copyFiles(ctx, original, root); err != nil {
return Cell{}, err
}
copyCheckpoint("copied")
// The independent journal is the exact history imported, before its header changes.
baseline, err := readCopySource(filepath.Join(root, legacyTranscript), -1)
if err != nil {
return Cell{}, err
}
baseline.Original = original
// Migration operates solely on the independent copy. No source inode is
// shared, even for files the migration itself links inside that copy.
if err := MigrateLegacy(root); err != nil {
return Cell{}, err
}
copyCheckpoint("migrated")
c, err := OpenAt(root, filepath.Base(root))
if err != nil {
return Cell{}, err
}
if err := prepare(ctx, c); err != nil {
return Cell{}, err
}
copyCheckpoint("prepared")
if err := ctx.Err(); err != nil {
return Cell{}, err
}
ready, err := json.Marshal(baseline)
if err != nil {
return Cell{}, err
}
if err := upgradenotice.Write(filepath.Join(root, CopyReadyPath), append(ready, '\n')); err != nil {
return Cell{}, err
}
copyCheckpoint("published")
return c, nil
}
// copyFiles copies regular files and symlinks without following links outside
// the source. File modes and times are preserved; sockets and locks are local.
func copyFiles(ctx context.Context, source, dest string) error {
return filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
rel, err := filepath.Rel(source, path)
if err != nil {
return err
}
out := filepath.Join(dest, rel)
info, err := entry.Info()
if err != nil {
return err
}
if entry.IsDir() {
return os.MkdirAll(out, info.Mode().Perm())
}
if info.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(path)
if err != nil {
return err
}
return os.Symlink(target, out)
}
if !info.Mode().IsRegular() {
return nil
}
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
f, err := os.OpenFile(out, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm())
if err != nil {
return err
}
copyCheckpoint("file-created")
_, err = io.Copy(f, in)
copyCheckpoint("file-written")
if err = errors.Join(err, f.Sync(), f.Close()); err != nil {
return err
}
return os.Chtimes(out, info.ModTime(), info.ModTime())
})
}