| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187 |
- package workflow
- import (
- "context"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- )
- // LocalFileAdapter implements FileAdapter for the local filesystem.
- //
- // # Path Structure (mirrors online Workspace API)
- //
- // <root>/<gid>/<path>
- //
- // where:
- // - root = base workspace root directory on disk
- // - gid = workspace/project identifier (same as RunParams.WorkspaceID in online runs)
- // - path = relative file path as written in workflow JSON "out" keys
- // (e.g., "/Output/result.txt" or "Output/result.txt")
- //
- // This mirrors the online workspace API structure where every file operation
- // requires both a gid (space identifier) and a relative path:
- //
- // POST /ih5/editor/workspace/writeFile {"gid":"xx","path":"Output/result.txt"}
- //
- // Using the same gid concept locally means the same workflow JSON runs
- // identically both online and offline — only the adapter differs.
- type LocalFileAdapter struct {
- root string // absolute path to base workspace root
- gid string // workspace / project identifier (subdirectory under root)
- }
- // NewLocalFileAdapter creates a LocalFileAdapter.
- //
- // - root: base directory that holds all workspaces (e.g. "/Users/alice/workspace")
- // - gid: workspace / project identifier (e.g. "project-abc" or a UUID)
- //
- // Files are stored as: root / gid / <relative-path>
- func NewLocalFileAdapter(root, gid string) (*LocalFileAdapter, error) {
- if root == "" {
- return nil, fmt.Errorf("LocalFileAdapter: root directory is required")
- }
- if gid == "" {
- return nil, fmt.Errorf("LocalFileAdapter: gid (workspace identifier) is required")
- }
- absRoot, err := filepath.Abs(root)
- if err != nil {
- return nil, fmt.Errorf("LocalFileAdapter: invalid root %q: %w", root, err)
- }
- return &LocalFileAdapter{root: absRoot, gid: gid}, nil
- }
- // Root returns the base workspace root directory.
- func (a *LocalFileAdapter) Root() string { return a.root }
- // GID returns the workspace / project identifier.
- func (a *LocalFileAdapter) GID() string { return a.gid }
- // WorkspaceDir returns the absolute path to this workspace (root/gid).
- func (a *LocalFileAdapter) WorkspaceDir() string {
- return filepath.Join(a.root, a.gid)
- }
- // resolvePath converts a workflow-relative path to an absolute local path.
- // Leading slashes are stripped (workflow "out" keys use "/Output/file.txt").
- // Path traversal outside the workspace boundary is rejected.
- func (a *LocalFileAdapter) resolvePath(path string) (string, error) {
- // Strip leading slash from workflow convention ("/Output/file.txt" → "Output/file.txt")
- clean := strings.TrimLeft(filepath.ToSlash(path), "/")
- if clean == "" {
- return "", fmt.Errorf("empty file path")
- }
- resolved := filepath.Join(a.WorkspaceDir(), filepath.FromSlash(clean))
- // Guard: resolved path must remain inside the workspace directory
- wsDir := a.WorkspaceDir()
- if !strings.HasPrefix(resolved+string(filepath.Separator), wsDir+string(filepath.Separator)) {
- return "", fmt.Errorf("path %q escapes workspace boundary", path)
- }
- return resolved, nil
- }
- // Read implements FileAdapter — reads a file from root/gid/path.
- func (a *LocalFileAdapter) Read(ctx context.Context, path string) ([]byte, error) {
- resolved, err := a.resolvePath(path)
- if err != nil {
- return nil, fmt.Errorf("LocalFileAdapter.Read: %w", err)
- }
- data, err := os.ReadFile(resolved)
- if err != nil {
- if os.IsNotExist(err) {
- return nil, &FileNotFoundError{Path: path}
- }
- return nil, fmt.Errorf("LocalFileAdapter.Read %q: %w", path, err)
- }
- return data, nil
- }
- // Write implements FileAdapter — writes content to root/gid/path.
- // Parent directories are created automatically.
- func (a *LocalFileAdapter) Write(ctx context.Context, path string, content []byte, mode WriteMode) error {
- resolved, err := a.resolvePath(path)
- if err != nil {
- return fmt.Errorf("LocalFileAdapter.Write: %w", err)
- }
- // Auto-create parent directories (mirrors online workspace behaviour)
- if err := os.MkdirAll(filepath.Dir(resolved), 0o755); err != nil {
- return fmt.Errorf("LocalFileAdapter.Write %q: cannot create parent dirs: %w", path, err)
- }
- switch mode {
- case WriteModeFailIfExists:
- if _, err := os.Stat(resolved); err == nil {
- return &FileExistsError{Path: path}
- }
- return writeFile(resolved, content)
- case WriteModeAppend:
- f, err := os.OpenFile(resolved, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
- if err != nil {
- return fmt.Errorf("LocalFileAdapter.Write(append) %q: %w", path, err)
- }
- defer f.Close()
- _, err = f.Write(content)
- return err
- case WriteModePrepend:
- existing, _ := os.ReadFile(resolved) // ignore not-found → treat as empty
- return writeFile(resolved, append(content, existing...))
- default: // WriteModeOverwrite (and any future mode)
- return writeFile(resolved, content)
- }
- }
- // Exists implements FileAdapter — returns true if root/gid/path exists as a file.
- func (a *LocalFileAdapter) Exists(ctx context.Context, path string) (bool, error) {
- resolved, err := a.resolvePath(path)
- if err != nil {
- return false, fmt.Errorf("LocalFileAdapter.Exists: %w", err)
- }
- info, err := os.Stat(resolved)
- if err == nil {
- return !info.IsDir(), nil
- }
- if os.IsNotExist(err) {
- return false, nil
- }
- return false, fmt.Errorf("LocalFileAdapter.Exists %q: %w", path, err)
- }
- // List implements FileAdapter — returns all files matching a glob pattern
- // relative to the workspace directory. The pattern follows the same convention
- // as the workflow registry.files declarations (e.g., "Output/*", "**/*.txt").
- // Returned paths have a leading slash to match workflow convention.
- func (a *LocalFileAdapter) List(ctx context.Context, pattern string) ([]string, error) {
- wsDir := a.WorkspaceDir()
- cleanPat := strings.TrimLeft(filepath.ToSlash(pattern), "/")
- absPattern := filepath.Join(wsDir, filepath.FromSlash(cleanPat))
- matches, err := filepath.Glob(absPattern)
- if err != nil {
- return nil, fmt.Errorf("LocalFileAdapter.List(%q): %w", pattern, err)
- }
- result := make([]string, 0, len(matches))
- for _, m := range matches {
- info, err := os.Stat(m)
- if err != nil || info.IsDir() {
- continue // skip directories and unreadable entries
- }
- rel, err := filepath.Rel(wsDir, m)
- if err != nil {
- continue
- }
- // Return with leading slash to match workflow "out" key convention
- result = append(result, "/"+filepath.ToSlash(rel))
- }
- return result, nil
- }
- // writeFile is a thin wrapper around os.WriteFile with 0644 permissions.
- func writeFile(path string, content []byte) error {
- return os.WriteFile(path, content, 0o644)
- }
|