workspace_file_adapter.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. package workflow
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "io"
  8. "net/http"
  9. "path/filepath"
  10. "strings"
  11. "time"
  12. )
  13. // WorkspaceFileAdapter implements FileAdapter using the online Workspace API.
  14. //
  15. // # Path Structure (matches online Workspace API)
  16. //
  17. // Every file operation is scoped by gid (workspace/project identifier):
  18. //
  19. // POST /ih5/editor/workspace/readFile {"gid":"xx","path":"Output/result.txt"}
  20. // POST /ih5/editor/workspace/writeFile {"gid":"xx","path":"Output/result.txt","content":"..."}
  21. //
  22. // The adapter uses the exact same gid concept as LocalFileAdapter, so the
  23. // same workflow JSON and the same gid value work for both local and online runs.
  24. //
  25. // Authentication is via Cookie header (ih5bearer token), matching the
  26. // DocCenter auth pattern used across the platform.
  27. type WorkspaceFileAdapter struct {
  28. baseURL string // e.g. "https://editor.visuallogic.ai"
  29. gid string // workspace / project identifier
  30. cookie string // ih5bearer=<token>
  31. httpClient *http.Client
  32. }
  33. // WorkspaceFileConfig holds configuration for WorkspaceFileAdapter.
  34. type WorkspaceFileConfig struct {
  35. // BaseURL is the platform base URL (e.g. "https://editor.visuallogic.ai").
  36. BaseURL string
  37. // GID is the workspace / project identifier.
  38. GID string
  39. // Cookie is the full cookie header value, e.g. "ih5bearer=<token>".
  40. // Read from /Users/ivx/Documents/docs/auth/.doccenter_cookie at runtime.
  41. Cookie string
  42. // Timeout for HTTP calls (default: 30s).
  43. Timeout time.Duration
  44. }
  45. // NewWorkspaceFileAdapter creates a WorkspaceFileAdapter.
  46. func NewWorkspaceFileAdapter(cfg WorkspaceFileConfig) (*WorkspaceFileAdapter, error) {
  47. if cfg.BaseURL == "" {
  48. return nil, fmt.Errorf("WorkspaceFileAdapter: BaseURL is required")
  49. }
  50. if cfg.GID == "" {
  51. return nil, fmt.Errorf("WorkspaceFileAdapter: GID is required")
  52. }
  53. if cfg.Cookie == "" {
  54. return nil, fmt.Errorf("WorkspaceFileAdapter: Cookie is required")
  55. }
  56. timeout := cfg.Timeout
  57. if timeout == 0 {
  58. timeout = 30 * time.Second
  59. }
  60. return &WorkspaceFileAdapter{
  61. baseURL: strings.TrimSuffix(cfg.BaseURL, "/"),
  62. gid: cfg.GID,
  63. cookie: cfg.Cookie,
  64. httpClient: &http.Client{
  65. Timeout: timeout,
  66. },
  67. }, nil
  68. }
  69. // GID returns the workspace / project identifier.
  70. func (a *WorkspaceFileAdapter) GID() string { return a.gid }
  71. // ── helpers ──────────────────────────────────────────────────────────────────
  72. // normPath strips the leading slash used in workflow JSON "out" keys.
  73. // "/Output/result.txt" → "Output/result.txt"
  74. func (a *WorkspaceFileAdapter) normPath(path string) string {
  75. return strings.TrimLeft(path, "/")
  76. }
  77. // post sends a POST request to the workspace API and decodes the JSON response.
  78. func (a *WorkspaceFileAdapter) post(ctx context.Context, endpoint string, body interface{}, out interface{}) error {
  79. bodyBytes, err := json.Marshal(body)
  80. if err != nil {
  81. return fmt.Errorf("marshal request: %w", err)
  82. }
  83. req, err := http.NewRequestWithContext(ctx, http.MethodPost,
  84. a.baseURL+"/ih5/editor/workspace/"+endpoint,
  85. bytes.NewReader(bodyBytes))
  86. if err != nil {
  87. return fmt.Errorf("create request: %w", err)
  88. }
  89. req.Header.Set("Content-Type", "application/json")
  90. req.Header.Set("Cookie", a.cookie)
  91. resp, err := a.httpClient.Do(req)
  92. if err != nil {
  93. return fmt.Errorf("POST %s: %w", endpoint, err)
  94. }
  95. defer resp.Body.Close()
  96. respBytes, err := io.ReadAll(resp.Body)
  97. if err != nil {
  98. return fmt.Errorf("POST %s: read response: %w", endpoint, err)
  99. }
  100. if resp.StatusCode != http.StatusOK {
  101. return fmt.Errorf("POST %s: HTTP %d: %s", endpoint, resp.StatusCode, string(respBytes))
  102. }
  103. if out != nil {
  104. if err := json.Unmarshal(respBytes, out); err != nil {
  105. return fmt.Errorf("POST %s: decode response: %w", endpoint, err)
  106. }
  107. }
  108. return nil
  109. }
  110. // ensureFile creates the file on the server if it does not already exist.
  111. // The online API requires createFile before the first writeFile.
  112. func (a *WorkspaceFileAdapter) ensureFile(ctx context.Context, path string) error {
  113. err := a.post(ctx, "createFile", map[string]string{
  114. "gid": a.gid,
  115. "path": path,
  116. }, nil)
  117. // Ignore "already exists" style errors (the server may return 200 or an
  118. // error body; treat any non-network error as "probably already exists").
  119. _ = err // best-effort create; writeFile will catch real failures
  120. return nil
  121. }
  122. // ── FileAdapter implementation ────────────────────────────────────────────────
  123. // Read implements FileAdapter — fetches file content from the workspace API.
  124. func (a *WorkspaceFileAdapter) Read(ctx context.Context, path string) ([]byte, error) {
  125. p := a.normPath(path)
  126. var resp struct {
  127. Version int `json:"version"`
  128. Status int `json:"status"`
  129. Content string `json:"content"`
  130. }
  131. if err := a.post(ctx, "readFile", map[string]string{
  132. "gid": a.gid,
  133. "path": p,
  134. }, &resp); err != nil {
  135. return nil, fmt.Errorf("WorkspaceFileAdapter.Read %q: %w", path, err)
  136. }
  137. return []byte(resp.Content), nil
  138. }
  139. // Write implements FileAdapter — writes content to the workspace API.
  140. // The online API is append-by-version (every write creates a new version),
  141. // so WriteMode semantics are emulated on the client side where possible.
  142. func (a *WorkspaceFileAdapter) Write(ctx context.Context, path string, content []byte, mode WriteMode) error {
  143. p := a.normPath(path)
  144. switch mode {
  145. case WriteModeFailIfExists:
  146. exists, err := a.Exists(ctx, path)
  147. if err != nil {
  148. return fmt.Errorf("WorkspaceFileAdapter.Write(failIfExists) check: %w", err)
  149. }
  150. if exists {
  151. return &FileExistsError{Path: path}
  152. }
  153. // Fall through to create + write
  154. case WriteModeAppend:
  155. existing, err := a.Read(ctx, path)
  156. if err == nil {
  157. content = append(existing, content...)
  158. }
  159. // If file doesn't exist yet, treat as fresh write
  160. case WriteModePrepend:
  161. existing, err := a.Read(ctx, path)
  162. if err == nil {
  163. content = append(content, existing...)
  164. }
  165. }
  166. // Ensure file exists before writing (online API requires createFile first)
  167. _ = a.ensureFile(ctx, p)
  168. var writeResp struct {
  169. GID string `json:"gid"`
  170. Path string `json:"path"`
  171. Version int `json:"version"`
  172. }
  173. if err := a.post(ctx, "writeFile", map[string]interface{}{
  174. "gid": a.gid,
  175. "path": p,
  176. "content": string(content),
  177. }, &writeResp); err != nil {
  178. return fmt.Errorf("WorkspaceFileAdapter.Write %q: %w", path, err)
  179. }
  180. return nil
  181. }
  182. // Exists implements FileAdapter — checks whether the file exists and has been written
  183. // (version > 0) in the workspace.
  184. func (a *WorkspaceFileAdapter) Exists(ctx context.Context, path string) (bool, error) {
  185. p := a.normPath(path)
  186. var resp struct {
  187. Files []struct {
  188. Path string `json:"path"`
  189. Version int `json:"version"`
  190. } `json:"files"`
  191. }
  192. if err := a.post(ctx, "listFile", map[string]string{
  193. "gid": a.gid,
  194. }, &resp); err != nil {
  195. return false, fmt.Errorf("WorkspaceFileAdapter.Exists: %w", err)
  196. }
  197. for _, f := range resp.Files {
  198. if f.Path == p && f.Version > 0 {
  199. return true, nil
  200. }
  201. }
  202. return false, nil
  203. }
  204. // List implements FileAdapter — returns all file paths in the workspace that
  205. // match the given glob pattern. Pattern uses the same convention as
  206. // LocalFileAdapter (e.g., "Output/*", "**/*.txt"). Leading slash is stripped.
  207. // Returned paths have a leading slash to match workflow convention.
  208. func (a *WorkspaceFileAdapter) List(ctx context.Context, pattern string) ([]string, error) {
  209. var resp struct {
  210. Files []struct {
  211. Path string `json:"path"`
  212. Version int `json:"version"`
  213. } `json:"files"`
  214. }
  215. if err := a.post(ctx, "listFile", map[string]string{
  216. "gid": a.gid,
  217. }, &resp); err != nil {
  218. return nil, fmt.Errorf("WorkspaceFileAdapter.List: %w", err)
  219. }
  220. // Convert pattern to a simple prefix/glob matcher
  221. cleanPat := strings.TrimLeft(pattern, "/")
  222. var result []string
  223. for _, f := range resp.Files {
  224. if f.Version == 0 {
  225. continue // file created but never written — skip
  226. }
  227. matched, err := matchGlob(cleanPat, f.Path)
  228. if err != nil || !matched {
  229. continue
  230. }
  231. result = append(result, "/"+f.Path)
  232. }
  233. return result, nil
  234. }
  235. // matchGlob checks whether path matches the given glob pattern.
  236. // Uses the same registry.files glob convention as the workflow engine.
  237. func matchGlob(pattern, path string) (bool, error) {
  238. // filepath.Match handles simple "*" and "?" wildcards.
  239. // For "**" (double-star) patterns, fall back to prefix match.
  240. if strings.Contains(pattern, "**") {
  241. prefix := strings.SplitN(pattern, "**", 2)[0]
  242. return strings.HasPrefix(path, prefix), nil
  243. }
  244. return filepath.Match(pattern, path)
  245. }