package workflow import ( "context" "fmt" "os" "path/filepath" "strings" ) // LocalFileAdapter implements FileAdapter for the local filesystem. // // # Path Structure (mirrors online Workspace API) // // // // // 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 / 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) }