Archive v1.2 milestone: ROADMAP + REQUIREMENTS + phases to milestones/. Evolve PROJECT.md with v1.2 validated requirements and key decisions. Update RETROSPECTIVE.md with v1.2 lessons and cross-milestone trends. Clean STATE.md for next milestone.
12 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 15-schema-migration-write-safety | 02 | execute | 1 |
|
true |
|
|
Purpose: Phase 16+ tag writers need to modify audio files without risk of corruption. This utility handles write-to-temp-then-rename, permission preservation, cross-directory rejection, and orphan cleanup. Callback API pattern: AtomicWrite(targetPath, func(tempFile *os.File) error).
Output: New backend/fileutil package with AtomicWrite function and comprehensive tests.
<execution_context> @/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md @/home/caleb/.config/opencode/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/15-schema-migration-write-safety/15-CONTEXT.mdFrom backend/events/cmd/genevents/main.go:
// writeAtomic writes data to a temporary file in the same directory as path,
// then renames it into place for atomic replacement.
func writeAtomic(path, data string) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".genevents-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.WriteString(data); err != nil {
_ = tmp.Close()
_ = os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return err
}
return os.Rename(tmpName, path)
}
Package doc comment:
// Package fileutil provides file system utilities for safe file operations.
package fileutil
API:
func AtomicWrite(targetPath string, fn func(tmp *os.File) error) error
Implementation requirements (from CONTEXT.md locked decisions):
-
Temp file naming: Use
targetPath + ".yj-tmp"as the temp file path. Do NOT useos.CreateTempwith random patterns — the deterministic suffix enables orphan cleanup. Example: writing tosong.mp3createssong.mp3.yj-tmp. -
Orphan cleanup: Before creating the temp file, check if
targetPath + ".yj-tmp"already exists (orphan from a previous crash). If it does, remove it. If removal fails (permissions, file lock), log at debug level and continue — don't block the operation. Accept an optional*slog.Loggerparameter or use a package-level approach. Per CONTEXT.md: "If an orphaned temp file can't be deleted (permissions, file lock), log a warning and continue."Decision: Use a
slog.Loggerparameter for consistency with codebase conventions. Signature becomes:func AtomicWrite(logger *slog.Logger, targetPath string, fn func(tmp *os.File) error) error -
Cross-filesystem rejection: Before the rename, verify the temp file and target are on the same filesystem. The simplest approach: since the temp file is created in the same directory as the target (using
filepath.Dir(targetPath)), same-directory guarantees same filesystem. But the function should still guard against the caller passing a targetPath that resolves across mount points. Use an explicit check: callos.Staton the parent directory and compare device IDs. Actually — the simpler and more robust approach per CONTEXT.md: "Cross-filesystem writes rejected with a clear error — no fallback to copy-then-delete." Since the temp file is always in the same dir as target,os.Renamewill fail if the directory itself is somehow cross-device. Letos.Renamereturn the error naturally, and wrap it with a clear message mentioning cross-filesystem. Define a sentinel error:var ErrCrossDevice = errors.New("atomic write: cross-device rename not supported")After
os.Renamefails, check if the error issyscall.EXDEV(cross-device link) and wrap withErrCrossDevice. For other rename errors, wrap normally. -
Permission preservation: Before writing,
os.Stat(targetPath)to get the current file mode. If the target exists, applyos.Chmod(tmpPath, mode)before the rename. If the target doesn't exist, use0644as default (per CONTEXT.md). -
Cleanup on error: If the callback
fnreturns an error, or ifClose()fails, or ifChmodfails — remove the temp file before returning. Always clean up on failure. -
Implementation flow:
a. Clean orphaned .yj-tmp file (if exists) b. Stat target for permissions (os.Stat, handle not-exist) c. Create temp file (os.Create on targetPath + ".yj-tmp") d. Call fn(tmpFile) — caller writes data e. Sync temp file (tmpFile.Sync() for durability) f. Close temp file g. Chmod temp file to match target permissions h. Rename temp file to target (atomic) i. On any error in d-h: remove temp file, return wrapped error
Sentinel errors:
var ErrCrossDevice = errors.New("atomic write: cross-device rename not supported")
What to avoid:
- Do NOT use
os.CreateTempwith random patterns — the deterministic.yj-tmpsuffix is a locked decision - Do NOT use
io.Copyfallback for cross-device — rejection is the correct behavior per CONTEXT.md - Do NOT make this audio-file-specific — it's a general-purpose utility per CONTEXT.md ("not audio-file-specific")
cd /mnt/vault/dev/golang/yellowjacket && go vet -tags webkit2_41 ./backend/fileutil/ && go build -tags webkit2_41 ./backend/fileutil/
backend/fileutil/atomicwrite.goexists with exportedAtomicWritefunctionErrCrossDevicesentinel error exported- Package compiles without errors
- Function signature:
AtomicWrite(logger *slog.Logger, targetPath string, fn func(tmp *os.File) error) error
Test cases to implement:
-
TestAtomicWrite_Success — Happy path:
- Create a target file with known content and specific permissions (e.g., 0o755)
- Call AtomicWrite to overwrite with new content
- Verify: target has new content, permissions preserved, no .yj-tmp file remains
-
TestAtomicWrite_NewFile — Target doesn't exist:
- Call AtomicWrite on a path that doesn't exist yet
- Verify: file created with new content, permissions are 0644, no .yj-tmp remains
-
TestAtomicWrite_CallbackError — Callback returns error:
- Call AtomicWrite with a callback that returns an error after partial write
- Verify: original file content is unchanged, no .yj-tmp file remains, error propagated
-
TestAtomicWrite_OrphanCleanup — Crash simulation:
- Create a
.yj-tmporphan file manually (simulating previous crash) - Call AtomicWrite on the same target
- Verify: orphan was cleaned up, new write succeeded, target has correct content
- Create a
-
TestAtomicWrite_CrossDirectoryRejection — Different directory:
- This test verifies the behavior when rename would cross filesystems
- Since we can't easily create cross-filesystem scenarios in CI, test that the temp file is always created in the same directory as the target:
- Call AtomicWrite on a file in
t.TempDir()/subdir/file.txt - During the callback, verify the temp file exists at
t.TempDir()/subdir/file.txt.yj-tmp - This confirms the temp file is always same-dir, making cross-device impossible in normal use
- Call AtomicWrite on a file in
-
TestAtomicWrite_PermissionPreservation — Table-driven with different modes:
- Test with 0o644, 0o755, 0o600
- Verify each mode is preserved after atomic write
-
TestAtomicWrite_SyncAndClose — Verify file is properly synced:
- Write substantial data (e.g., 1MB)
- Verify target file size matches expected after AtomicWrite
All tests must follow codebase patterns:
package fileutil(internal test, same package)t.Parallel()at top level and in subtestst.TempDir()for all file operationst.Fatalffor setup failures,t.Errorffor assertion failures- No assertion libraries — raw comparisons
//nolint:mndfor magic numbers in test data where needed
Logger for tests: Use slog.Default() — tests don't need special log handling.
cd /mnt/vault/dev/golang/yellowjacket && go test -tags webkit2_41 -v -race -count=1 -timeout 30s ./backend/fileutil/
- All 7 test functions pass
- Tests verify: successful write, new file creation, callback error rollback, orphan cleanup, same-dir temp file, permission preservation, proper sync
- Race detector passes (no concurrency issues)
- make test passes (full test suite including new tests)
- make lint passes (no lint violations in new code)
<success_criteria>
backend/fileutil/package exists withAtomicWritefunction andErrCrossDevicesentinel- AtomicWrite uses
.yj-tmpsuffix, callback API, permission preservation, orphan cleanup, cross-device rejection - 7 test functions covering success, new file, callback error, orphan cleanup, same-dir, permissions, sync
- All tests pass with race detector
- Full
make testandmake lintpass </success_criteria>