local_file_adapter.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. package workflow
  2. import (
  3. "context"
  4. "fmt"
  5. "os"
  6. "path/filepath"
  7. "strings"
  8. )
  9. // LocalFileAdapter implements FileAdapter for the local filesystem.
  10. //
  11. // # Path Structure (mirrors online Workspace API)
  12. //
  13. // <root>/<gid>/<path>
  14. //
  15. // where:
  16. // - root = base workspace root directory on disk
  17. // - gid = workspace/project identifier (same as RunParams.WorkspaceID in online runs)
  18. // - path = relative file path as written in workflow JSON "out" keys
  19. // (e.g., "/Output/result.txt" or "Output/result.txt")
  20. //
  21. // This mirrors the online workspace API structure where every file operation
  22. // requires both a gid (space identifier) and a relative path:
  23. //
  24. // POST /ih5/editor/workspace/writeFile {"gid":"xx","path":"Output/result.txt"}
  25. //
  26. // Using the same gid concept locally means the same workflow JSON runs
  27. // identically both online and offline — only the adapter differs.
  28. type LocalFileAdapter struct {
  29. root string // absolute path to base workspace root
  30. gid string // workspace / project identifier (subdirectory under root)
  31. }
  32. // NewLocalFileAdapter creates a LocalFileAdapter.
  33. //
  34. // - root: base directory that holds all workspaces (e.g. "/Users/alice/workspace")
  35. // - gid: workspace / project identifier (e.g. "project-abc" or a UUID)
  36. //
  37. // Files are stored as: root / gid / <relative-path>
  38. func NewLocalFileAdapter(root, gid string) (*LocalFileAdapter, error) {
  39. if root == "" {
  40. return nil, fmt.Errorf("LocalFileAdapter: root directory is required")
  41. }
  42. if gid == "" {
  43. return nil, fmt.Errorf("LocalFileAdapter: gid (workspace identifier) is required")
  44. }
  45. absRoot, err := filepath.Abs(root)
  46. if err != nil {
  47. return nil, fmt.Errorf("LocalFileAdapter: invalid root %q: %w", root, err)
  48. }
  49. return &LocalFileAdapter{root: absRoot, gid: gid}, nil
  50. }
  51. // Root returns the base workspace root directory.
  52. func (a *LocalFileAdapter) Root() string { return a.root }
  53. // GID returns the workspace / project identifier.
  54. func (a *LocalFileAdapter) GID() string { return a.gid }
  55. // WorkspaceDir returns the absolute path to this workspace (root/gid).
  56. func (a *LocalFileAdapter) WorkspaceDir() string {
  57. return filepath.Join(a.root, a.gid)
  58. }
  59. // resolvePath converts a workflow-relative path to an absolute local path.
  60. // Leading slashes are stripped (workflow "out" keys use "/Output/file.txt").
  61. // Path traversal outside the workspace boundary is rejected.
  62. func (a *LocalFileAdapter) resolvePath(path string) (string, error) {
  63. // Strip leading slash from workflow convention ("/Output/file.txt" → "Output/file.txt")
  64. clean := strings.TrimLeft(filepath.ToSlash(path), "/")
  65. if clean == "" {
  66. return "", fmt.Errorf("empty file path")
  67. }
  68. resolved := filepath.Join(a.WorkspaceDir(), filepath.FromSlash(clean))
  69. // Guard: resolved path must remain inside the workspace directory
  70. wsDir := a.WorkspaceDir()
  71. if !strings.HasPrefix(resolved+string(filepath.Separator), wsDir+string(filepath.Separator)) {
  72. return "", fmt.Errorf("path %q escapes workspace boundary", path)
  73. }
  74. return resolved, nil
  75. }
  76. // Read implements FileAdapter — reads a file from root/gid/path.
  77. func (a *LocalFileAdapter) Read(ctx context.Context, path string) ([]byte, error) {
  78. resolved, err := a.resolvePath(path)
  79. if err != nil {
  80. return nil, fmt.Errorf("LocalFileAdapter.Read: %w", err)
  81. }
  82. data, err := os.ReadFile(resolved)
  83. if err != nil {
  84. if os.IsNotExist(err) {
  85. return nil, &FileNotFoundError{Path: path}
  86. }
  87. return nil, fmt.Errorf("LocalFileAdapter.Read %q: %w", path, err)
  88. }
  89. return data, nil
  90. }
  91. // Write implements FileAdapter — writes content to root/gid/path.
  92. // Parent directories are created automatically.
  93. func (a *LocalFileAdapter) Write(ctx context.Context, path string, content []byte, mode WriteMode) error {
  94. resolved, err := a.resolvePath(path)
  95. if err != nil {
  96. return fmt.Errorf("LocalFileAdapter.Write: %w", err)
  97. }
  98. // Auto-create parent directories (mirrors online workspace behaviour)
  99. if err := os.MkdirAll(filepath.Dir(resolved), 0o755); err != nil {
  100. return fmt.Errorf("LocalFileAdapter.Write %q: cannot create parent dirs: %w", path, err)
  101. }
  102. switch mode {
  103. case WriteModeFailIfExists:
  104. if _, err := os.Stat(resolved); err == nil {
  105. return &FileExistsError{Path: path}
  106. }
  107. return writeFile(resolved, content)
  108. case WriteModeAppend:
  109. f, err := os.OpenFile(resolved, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
  110. if err != nil {
  111. return fmt.Errorf("LocalFileAdapter.Write(append) %q: %w", path, err)
  112. }
  113. defer f.Close()
  114. _, err = f.Write(content)
  115. return err
  116. case WriteModePrepend:
  117. existing, _ := os.ReadFile(resolved) // ignore not-found → treat as empty
  118. return writeFile(resolved, append(content, existing...))
  119. default: // WriteModeOverwrite (and any future mode)
  120. return writeFile(resolved, content)
  121. }
  122. }
  123. // Exists implements FileAdapter — returns true if root/gid/path exists as a file.
  124. func (a *LocalFileAdapter) Exists(ctx context.Context, path string) (bool, error) {
  125. resolved, err := a.resolvePath(path)
  126. if err != nil {
  127. return false, fmt.Errorf("LocalFileAdapter.Exists: %w", err)
  128. }
  129. info, err := os.Stat(resolved)
  130. if err == nil {
  131. return !info.IsDir(), nil
  132. }
  133. if os.IsNotExist(err) {
  134. return false, nil
  135. }
  136. return false, fmt.Errorf("LocalFileAdapter.Exists %q: %w", path, err)
  137. }
  138. // List implements FileAdapter — returns all files matching a glob pattern
  139. // relative to the workspace directory. The pattern follows the same convention
  140. // as the workflow registry.files declarations (e.g., "Output/*", "**/*.txt").
  141. // Returned paths have a leading slash to match workflow convention.
  142. func (a *LocalFileAdapter) List(ctx context.Context, pattern string) ([]string, error) {
  143. wsDir := a.WorkspaceDir()
  144. cleanPat := strings.TrimLeft(filepath.ToSlash(pattern), "/")
  145. absPattern := filepath.Join(wsDir, filepath.FromSlash(cleanPat))
  146. matches, err := filepath.Glob(absPattern)
  147. if err != nil {
  148. return nil, fmt.Errorf("LocalFileAdapter.List(%q): %w", pattern, err)
  149. }
  150. result := make([]string, 0, len(matches))
  151. for _, m := range matches {
  152. info, err := os.Stat(m)
  153. if err != nil || info.IsDir() {
  154. continue // skip directories and unreadable entries
  155. }
  156. rel, err := filepath.Rel(wsDir, m)
  157. if err != nil {
  158. continue
  159. }
  160. // Return with leading slash to match workflow "out" key convention
  161. result = append(result, "/"+filepath.ToSlash(rel))
  162. }
  163. return result, nil
  164. }
  165. // writeFile is a thin wrapper around os.WriteFile with 0644 permissions.
  166. func writeFile(path string, content []byte) error {
  167. return os.WriteFile(path, content, 0o644)
  168. }