From c56fcedcb14ce284e12c70bce7d5d1a76d1f2714 Mon Sep 17 00:00:00 2001 From: mosir Date: Tue, 24 Feb 2026 13:22:52 +0800 Subject: [PATCH 1/4] refactor(pkg/utils): add unified atomic file write utility --- pkg/agent/memory.go | 13 +++-- pkg/auth/store.go | 11 ++--- pkg/config/config.go | 10 ++-- pkg/cron/service.go | 10 ++-- pkg/heartbeat/service.go | 3 +- pkg/skills/installer.go | 6 ++- pkg/state/state.go | 27 +++-------- pkg/tools/filesystem.go | 57 ++++++++++++---------- pkg/tools/skills_install.go | 3 +- pkg/utils/file.go | 97 +++++++++++++++++++++++++++++++++++++ 10 files changed, 166 insertions(+), 71 deletions(-) create mode 100644 pkg/utils/file.go diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index dd5f4441c..7e952be55 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -12,6 +12,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/sipeed/picoclaw/pkg/utils" ) // MemoryStore manages persistent memory for the agent. @@ -58,7 +60,9 @@ func (ms *MemoryStore) ReadLongTerm() string { // WriteLongTerm writes content to the long-term memory file (MEMORY.md). func (ms *MemoryStore) WriteLongTerm(content string) error { - return os.WriteFile(ms.memoryFile, []byte(content), 0o644) + // Use unified atomic write utility with explicit sync for flash storage reliability. + // Using 0o600 (owner read/write only) for secure default permissions. + return utils.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600) } // ReadToday reads today's daily note. @@ -78,7 +82,9 @@ func (ms *MemoryStore) AppendToday(content string) error { // Ensure month directory exists monthDir := filepath.Dir(todayFile) - os.MkdirAll(monthDir, 0o755) + if err := os.MkdirAll(monthDir, 0o755); err != nil { + return err + } var existingContent string if data, err := os.ReadFile(todayFile); err == nil { @@ -95,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error { newContent = existingContent + "\n" + content } - return os.WriteFile(todayFile, []byte(newContent), 0o644) + // Use unified atomic write utility with explicit sync for flash storage reliability. + return utils.WriteFileAtomic(todayFile, []byte(newContent), 0o600) } // GetRecentDailyNotes returns daily notes from the last N days. diff --git a/pkg/auth/store.go b/pkg/auth/store.go index 64708421b..34d7d8a3f 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "time" + + "github.com/sipeed/picoclaw/pkg/utils" ) type AuthCredential struct { @@ -63,16 +65,13 @@ func LoadStore() (*AuthStore, error) { func SaveStore(store *AuthStore) error { path := authFilePath() - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - data, err := json.MarshalIndent(store, "", " ") if err != nil { return err } - return os.WriteFile(path, data, 0o600) + + // Use unified atomic write utility with explicit sync for flash storage reliability. + return utils.WriteFileAtomic(path, data, 0o600) } func GetCredential(provider string) (*AuthCredential, error) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 2595398c7..9d2860407 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,10 +4,10 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "sync/atomic" "github.com/caarlos0/env/v11" + "github.com/sipeed/picoclaw/pkg/utils" ) // rrCounter is a global counter for round-robin load balancing across models. @@ -526,12 +526,8 @@ func SaveConfig(path string, cfg *Config) error { return err } - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - - return os.WriteFile(path, data, 0o600) + // Use unified atomic write utility with explicit sync for flash storage reliability. + return utils.WriteFileAtomic(path, data, 0o600) } func (c *Config) WorkspacePath() string { diff --git a/pkg/cron/service.go b/pkg/cron/service.go index e699a44b5..7501117f5 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -7,11 +7,11 @@ import ( "fmt" "log" "os" - "path/filepath" "sync" "time" "github.com/adhocore/gronx" + "github.com/sipeed/picoclaw/pkg/utils" ) type CronSchedule struct { @@ -330,17 +330,13 @@ func (cs *CronService) loadStore() error { } func (cs *CronService) saveStoreUnsafe() error { - dir := filepath.Dir(cs.storePath) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - data, err := json.MarshalIndent(cs.store, "", " ") if err != nil { return err } - return os.WriteFile(cs.storePath, data, 0o600) + // Use unified atomic write utility with explicit sync for flash storage reliability. + return utils.WriteFileAtomic(cs.storePath, data, 0o600) } func (cs *CronService) AddJob( diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index 75d6248b9..2108fbf8c 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -19,6 +19,7 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" ) const ( @@ -275,7 +276,7 @@ This file contains tasks for the heartbeat service to check periodically. Add your heartbeat tasks below this line: ` - if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil { + if err := utils.WriteFileAtomic(heartbeatPath, []byte(defaultContent), 0o644); err != nil { hs.logError("Failed to create default HEARTBEAT.md: %v", err) } else { hs.logInfo("Created default HEARTBEAT.md template") diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go index 3210509df..12ffe33e6 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -9,6 +9,8 @@ import ( "os" "path/filepath" "time" + + "github.com/sipeed/picoclaw/pkg/utils" ) type SkillInstaller struct { @@ -64,7 +66,9 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er } skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, body, 0o644); err != nil { + + // Use unified atomic write utility with explicit sync for flash storage reliability. + if err := utils.WriteFileAtomic(skillPath, body, 0o600); err != nil { return fmt.Errorf("failed to write skill file: %w", err) } diff --git a/pkg/state/state.go b/pkg/state/state.go index 1a92f82ed..efccc9332 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -8,6 +8,8 @@ import ( "path/filepath" "sync" "time" + + "github.com/sipeed/picoclaw/pkg/utils" ) // State represents the persistent state for a workspace. @@ -124,33 +126,20 @@ func (sm *Manager) GetTimestamp() time.Time { // saveAtomic performs an atomic save using temp file + rename. // This ensures that the state file is never corrupted: // 1. Write to a temp file -// 2. Rename temp file to target (atomic on POSIX systems) -// 3. If rename fails, cleanup the temp file +// 2. Sync to disk (critical for SD cards/flash storage) +// 3. Rename temp file to target (atomic on POSIX systems) +// 4. If rename fails, cleanup the temp file // // Must be called with the lock held. func (sm *Manager) saveAtomic() error { - // Create temp file in the same directory as the target - tempFile := sm.stateFile + ".tmp" - - // Marshal state to JSON + // Use unified atomic write utility with explicit sync for flash storage reliability. + // Using 0o600 (owner read/write only) for secure default permissions. data, err := json.MarshalIndent(sm.state, "", " ") if err != nil { return fmt.Errorf("failed to marshal state: %w", err) } - // Write to temp file - if err := os.WriteFile(tempFile, data, 0o644); err != nil { - return fmt.Errorf("failed to write temp file: %w", err) - } - - // Atomic rename from temp to target - if err := os.Rename(tempFile, sm.stateFile); err != nil { - // Cleanup temp file if rename fails - os.Remove(tempFile) - return fmt.Errorf("failed to rename temp file: %w", err) - } - - return nil + return utils.WriteFileAtomic(sm.stateFile, data, 0o600) } // load loads the state from disk. diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 37db8b4ae..649707617 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/sipeed/picoclaw/pkg/utils" ) // validatePath ensures the given path is within the workspace if restrict is true. @@ -276,25 +278,9 @@ func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) { } func (h *hostFs) WriteFile(path string, data []byte) error { - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("failed to create parent directories: %w", err) - } - - // We use a "write-then-rename" pattern here to ensure an atomic write. - // This prevents the target file from being left in a truncated or partial state - // if the operation is interrupted, as the rename operation is atomic on Linux. - tmpPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano()) - if err := os.WriteFile(tmpPath, data, 0o644); err != nil { - os.Remove(tmpPath) // Ensure cleanup of partial/empty temp file - return fmt.Errorf("failed to write temp file: %w", err) - } - - if err := os.Rename(tmpPath, path); err != nil { - os.Remove(tmpPath) - return fmt.Errorf("failed to replace original file: %w", err) - } - return nil + // Use unified atomic write utility with explicit sync for flash storage reliability. + // Using 0o600 (owner read/write only) for secure default permissions. + return utils.WriteFileAtomic(path, data, 0o600) } // sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. @@ -351,14 +337,33 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error { } } - // We use a "write-then-rename" pattern here to ensure an atomic write. - // This prevents the target file from being left in a truncated or partial state - // if the operation is interrupted, as the rename operation is atomic on Linux. - tmpRelPath := fmt.Sprintf("%s.%d.tmp", relPath, time.Now().UnixNano()) + // Use atomic write pattern with explicit sync for flash storage reliability. + // Using 0o600 (owner read/write only) for secure default permissions. + tmpRelPath := fmt.Sprintf(".tmp-%d.tmp", time.Now().UnixNano()) - if err := root.WriteFile(tmpRelPath, data, 0o644); err != nil { - root.Remove(tmpRelPath) // Ensure cleanup of partial/empty temp file - return fmt.Errorf("failed to write to temp file: %w", err) + tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + root.Remove(tmpRelPath) + return fmt.Errorf("failed to open temp file: %w", err) + } + + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + root.Remove(tmpRelPath) + return fmt.Errorf("failed to write temp file: %w", err) + } + + // CRITICAL: Force sync to storage medium before rename. + // This ensures data is physically written to disk, not just cached. + if err := tmpFile.Sync(); err != nil { + tmpFile.Close() + root.Remove(tmpRelPath) + return fmt.Errorf("failed to sync temp file: %w", err) + } + + if err := tmpFile.Close(); err != nil { + root.Remove(tmpRelPath) + return fmt.Errorf("failed to close temp file: %w", err) } if err := root.Rename(tmpRelPath, relPath); err != nil { diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go index 55c0b678d..57d29f355 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/skills_install.go @@ -197,5 +197,6 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error { return err } - return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0o644) + // Use unified atomic write utility with explicit sync for flash storage reliability. + return utils.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600) } diff --git a/pkg/utils/file.go b/pkg/utils/file.go new file mode 100644 index 000000000..74a83712a --- /dev/null +++ b/pkg/utils/file.go @@ -0,0 +1,97 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package utils + +import ( + "fmt" + "os" + "path/filepath" +) + +// WriteFileAtomic atomically writes data to a file using a temp file + rename pattern. +// +// This guarantees that the target file is either: +// - Completely written with the new data +// - Unchanged (if write fails or power loss during write) +// +// The function: +// 1. Creates a temp file in the same directory +// 2. Writes data to temp file +// 3. Syncs to disk (critical for SD cards/flash storage) +// 4. Sets file permissions +// 5. Atomically renames temp file to target path +// +// Parameters: +// - path: Target file path +// - data: Data to write +// - perm: File permission mode (e.g., 0o600 for secure, 0o644 for readable) +// +// Returns: +// - Error if any step fails, nil on success +// +// Example: +// +// // Secure config file (owner read/write only) +// err := utils.WriteFileAtomic("config.json", data, 0o600) +// +// // Public readable file +// err := utils.WriteFileAtomic("public.txt", data, 0o644) +func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Create temp file in the same directory (ensures atomic rename works) + tmpFile, err := os.CreateTemp(dir, ".tmp-*.tmp") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + + // Cleanup on error: ensure temp file is removed if anything fails + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tmpPath) + } + }() + + // Write data to temp file + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + return fmt.Errorf("failed to write temp file: %w", err) + } + + // CRITICAL: Force sync to storage medium before rename. + // This ensures data is physically written to disk, not just cached. + // Essential for SD cards, eMMC, and other flash storage on edge devices. + if err := tmpFile.Sync(); err != nil { + tmpFile.Close() + return fmt.Errorf("failed to sync temp file: %w", err) + } + + // Set file permissions + if err := tmpFile.Chmod(perm); err != nil { + tmpFile.Close() + return fmt.Errorf("failed to set permissions: %w", err) + } + + // Close file before rename + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + // Atomic rename: temp file becomes the target + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("failed to rename temp file: %w", err) + } + + // Success: skip cleanup + cleanup = false + return nil +} From 4aed3591e762c61a5d1a27220ea7b70ff98031fa Mon Sep 17 00:00:00 2001 From: mosir Date: Tue, 24 Feb 2026 23:49:40 +0800 Subject: [PATCH 2/4] refactor(pkg/utils): improve WriteFileAtomic with stronger durability guarantees --- pkg/tools/filesystem.go | 11 +++++++-- pkg/utils/file.go | 49 +++++++++++++++++++++++++++++------------ 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 649707617..e40782ab6 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -339,9 +339,9 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error { // Use atomic write pattern with explicit sync for flash storage reliability. // Using 0o600 (owner read/write only) for secure default permissions. - tmpRelPath := fmt.Sprintf(".tmp-%d.tmp", time.Now().UnixNano()) + tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano()) - tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { root.Remove(tmpRelPath) return fmt.Errorf("failed to open temp file: %w", err) @@ -370,6 +370,13 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error { root.Remove(tmpRelPath) return fmt.Errorf("failed to rename temp file over target: %w", err) } + + // Sync directory to ensure rename is durable + if dirFile, err := root.Open("."); err == nil { + _ = dirFile.Sync() + dirFile.Close() + } + return nil }) } diff --git a/pkg/utils/file.go b/pkg/utils/file.go index 74a83712a..e7c2675e4 100644 --- a/pkg/utils/file.go +++ b/pkg/utils/file.go @@ -10,20 +10,28 @@ import ( "fmt" "os" "path/filepath" + "time" ) // WriteFileAtomic atomically writes data to a file using a temp file + rename pattern. // // This guarantees that the target file is either: // - Completely written with the new data -// - Unchanged (if write fails or power loss during write) +// - Unchanged (if any step fails before rename) // // The function: -// 1. Creates a temp file in the same directory +// 1. Creates a temp file in the same directory (original untouched) // 2. Writes data to temp file -// 3. Syncs to disk (critical for SD cards/flash storage) +// 3. Syncs data to disk (critical for SD cards/flash storage) // 4. Sets file permissions -// 5. Atomically renames temp file to target path +// 5. Syncs directory metadata (ensures rename is durable) +// 6. Atomically renames temp file to target path +// +// Safety guarantees: +// - Original file is NEVER modified until successful rename +// - Temp file is always cleaned up on error +// - Data is flushed to physical storage before rename +// - Directory entry is synced to prevent orphaned inodes // // Parameters: // - path: Target file path @@ -47,51 +55,64 @@ func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { } // Create temp file in the same directory (ensures atomic rename works) - tmpFile, err := os.CreateTemp(dir, ".tmp-*.tmp") + // Using a hidden prefix (.tmp-) to avoid issues with some tools + tmpFile, err := os.OpenFile( + filepath.Join(dir, fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano())), + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + perm, + ) if err != nil { return fmt.Errorf("failed to create temp file: %w", err) } - tmpPath := tmpFile.Name() - // Cleanup on error: ensure temp file is removed if anything fails + tmpPath := tmpFile.Name() cleanup := true + defer func() { if cleanup { + tmpFile.Close() _ = os.Remove(tmpPath) } }() // Write data to temp file + // Note: Original file is untouched at this point if _, err := tmpFile.Write(data); err != nil { - tmpFile.Close() return fmt.Errorf("failed to write temp file: %w", err) } - // CRITICAL: Force sync to storage medium before rename. + // CRITICAL: Force sync to storage medium before any other operations. // This ensures data is physically written to disk, not just cached. // Essential for SD cards, eMMC, and other flash storage on edge devices. if err := tmpFile.Sync(); err != nil { - tmpFile.Close() return fmt.Errorf("failed to sync temp file: %w", err) } - // Set file permissions + // Set file permissions before closing if err := tmpFile.Chmod(perm); err != nil { - tmpFile.Close() return fmt.Errorf("failed to set permissions: %w", err) } - // Close file before rename + // Close file before rename (required on Windows) if err := tmpFile.Close(); err != nil { return fmt.Errorf("failed to close temp file: %w", err) } // Atomic rename: temp file becomes the target + // On POSIX: rename() is atomic + // On Windows: Rename() is atomic for files if err := os.Rename(tmpPath, path); err != nil { return fmt.Errorf("failed to rename temp file: %w", err) } - // Success: skip cleanup + // Sync directory to ensure rename is durable + // This prevents the renamed file from disappearing after a crash + if dirFile, err := os.Open(dir); err == nil { + _ = dirFile.Sync() + dirFile.Close() + } + + // Success: skip cleanup (file was renamed, no temp to remove) cleanup = false return nil } From 11996f1a0b7cdc91a2f6978c4ca06c2dda7f9bd3 Mon Sep 17 00:00:00 2001 From: mosir Date: Tue, 24 Feb 2026 23:57:13 +0800 Subject: [PATCH 3/4] refactor(pkg): move atomic file write to dedicated fileutil package --- pkg/agent/memory.go | 6 +++--- pkg/auth/store.go | 4 ++-- pkg/config/config.go | 4 ++-- pkg/cron/service.go | 4 ++-- pkg/{utils => fileutil}/file.go | 3 ++- pkg/heartbeat/service.go | 4 ++-- pkg/skills/installer.go | 4 ++-- pkg/state/state.go | 4 ++-- pkg/tools/filesystem.go | 4 ++-- pkg/tools/skills_install.go | 3 ++- 10 files changed, 21 insertions(+), 19 deletions(-) rename pkg/{utils => fileutil}/file.go (97%) diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index 7e952be55..87a687479 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -13,7 +13,7 @@ import ( "strings" "time" - "github.com/sipeed/picoclaw/pkg/utils" + "github.com/sipeed/picoclaw/pkg/fileutil" ) // MemoryStore manages persistent memory for the agent. @@ -62,7 +62,7 @@ func (ms *MemoryStore) ReadLongTerm() string { func (ms *MemoryStore) WriteLongTerm(content string) error { // Use unified atomic write utility with explicit sync for flash storage reliability. // Using 0o600 (owner read/write only) for secure default permissions. - return utils.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600) + return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600) } // ReadToday reads today's daily note. @@ -102,7 +102,7 @@ func (ms *MemoryStore) AppendToday(content string) error { } // Use unified atomic write utility with explicit sync for flash storage reliability. - return utils.WriteFileAtomic(todayFile, []byte(newContent), 0o600) + return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600) } // GetRecentDailyNotes returns daily notes from the last N days. diff --git a/pkg/auth/store.go b/pkg/auth/store.go index 34d7d8a3f..283dc6977 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -6,7 +6,7 @@ import ( "path/filepath" "time" - "github.com/sipeed/picoclaw/pkg/utils" + "github.com/sipeed/picoclaw/pkg/fileutil" ) type AuthCredential struct { @@ -71,7 +71,7 @@ func SaveStore(store *AuthStore) error { } // Use unified atomic write utility with explicit sync for flash storage reliability. - return utils.WriteFileAtomic(path, data, 0o600) + return fileutil.WriteFileAtomic(path, data, 0o600) } func GetCredential(provider string) (*AuthCredential, error) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 9d2860407..67b71cfda 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,7 +7,7 @@ import ( "sync/atomic" "github.com/caarlos0/env/v11" - "github.com/sipeed/picoclaw/pkg/utils" + "github.com/sipeed/picoclaw/pkg/fileutil" ) // rrCounter is a global counter for round-robin load balancing across models. @@ -527,7 +527,7 @@ func SaveConfig(path string, cfg *Config) error { } // Use unified atomic write utility with explicit sync for flash storage reliability. - return utils.WriteFileAtomic(path, data, 0o600) + return fileutil.WriteFileAtomic(path, data, 0o600) } func (c *Config) WorkspacePath() string { diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 7501117f5..f14d86bcb 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -11,7 +11,7 @@ import ( "time" "github.com/adhocore/gronx" - "github.com/sipeed/picoclaw/pkg/utils" + "github.com/sipeed/picoclaw/pkg/fileutil" ) type CronSchedule struct { @@ -336,7 +336,7 @@ func (cs *CronService) saveStoreUnsafe() error { } // Use unified atomic write utility with explicit sync for flash storage reliability. - return utils.WriteFileAtomic(cs.storePath, data, 0o600) + return fileutil.WriteFileAtomic(cs.storePath, data, 0o600) } func (cs *CronService) AddJob( diff --git a/pkg/utils/file.go b/pkg/fileutil/file.go similarity index 97% rename from pkg/utils/file.go rename to pkg/fileutil/file.go index e7c2675e4..7ca872374 100644 --- a/pkg/utils/file.go +++ b/pkg/fileutil/file.go @@ -4,7 +4,8 @@ // // Copyright (c) 2026 PicoClaw contributors -package utils +// Package fileutil provides file manipulation utilities. +package fileutil import ( "fmt" diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index 2108fbf8c..3aeca6188 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -19,7 +19,7 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" - "github.com/sipeed/picoclaw/pkg/utils" + "github.com/sipeed/picoclaw/pkg/fileutil" ) const ( @@ -276,7 +276,7 @@ This file contains tasks for the heartbeat service to check periodically. Add your heartbeat tasks below this line: ` - if err := utils.WriteFileAtomic(heartbeatPath, []byte(defaultContent), 0o644); err != nil { + if err := fileutil.WriteFileAtomic(heartbeatPath, []byte(defaultContent), 0o644); err != nil { hs.logError("Failed to create default HEARTBEAT.md: %v", err) } else { hs.logInfo("Created default HEARTBEAT.md template") diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go index 12ffe33e6..d6ff5f3a3 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -10,7 +10,7 @@ import ( "path/filepath" "time" - "github.com/sipeed/picoclaw/pkg/utils" + "github.com/sipeed/picoclaw/pkg/fileutil" ) type SkillInstaller struct { @@ -68,7 +68,7 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er skillPath := filepath.Join(skillDir, "SKILL.md") // Use unified atomic write utility with explicit sync for flash storage reliability. - if err := utils.WriteFileAtomic(skillPath, body, 0o600); err != nil { + if err := fileutil.WriteFileAtomic(skillPath, body, 0o600); err != nil { return fmt.Errorf("failed to write skill file: %w", err) } diff --git a/pkg/state/state.go b/pkg/state/state.go index efccc9332..1663faa4c 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/sipeed/picoclaw/pkg/utils" + "github.com/sipeed/picoclaw/pkg/fileutil" ) // State represents the persistent state for a workspace. @@ -139,7 +139,7 @@ func (sm *Manager) saveAtomic() error { return fmt.Errorf("failed to marshal state: %w", err) } - return utils.WriteFileAtomic(sm.stateFile, data, 0o600) + return fileutil.WriteFileAtomic(sm.stateFile, data, 0o600) } // load loads the state from disk. diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index e40782ab6..03d461dcc 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/sipeed/picoclaw/pkg/utils" + "github.com/sipeed/picoclaw/pkg/fileutil" ) // validatePath ensures the given path is within the workspace if restrict is true. @@ -280,7 +280,7 @@ func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) { func (h *hostFs) WriteFile(path string, data []byte) error { // Use unified atomic write utility with explicit sync for flash storage reliability. // Using 0o600 (owner read/write only) for secure default permissions. - return utils.WriteFileAtomic(path, data, 0o600) + return fileutil.WriteFileAtomic(path, data, 0o600) } // sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go index 57d29f355..71bfe730b 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/skills_install.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/utils" @@ -198,5 +199,5 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error { } // Use unified atomic write utility with explicit sync for flash storage reliability. - return utils.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600) + return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600) } From 433af435a9b27112b0bab7e8e206aede55c8164a Mon Sep 17 00:00:00 2001 From: mosir Date: Thu, 26 Feb 2026 20:38:11 +0800 Subject: [PATCH 4/4] style: fix gci import grouping in config, cron, and skills installer --- pkg/config/config.go | 1 + pkg/cron/service.go | 1 + pkg/skills/installer.go | 3 +-- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index ddfa35dc9..ca5803c35 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,6 +7,7 @@ import ( "sync/atomic" "github.com/caarlos0/env/v11" + "github.com/sipeed/picoclaw/pkg/fileutil" ) diff --git a/pkg/cron/service.go b/pkg/cron/service.go index f14d86bcb..6962041c1 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -11,6 +11,7 @@ import ( "time" "github.com/adhocore/gronx" + "github.com/sipeed/picoclaw/pkg/fileutil" ) diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go index 31b5a3dbd..20f6a49d9 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -10,9 +10,8 @@ import ( "path/filepath" "time" - "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/fileutil" + "github.com/sipeed/picoclaw/pkg/utils" ) type SkillInstaller struct {