package workflow import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "path/filepath" "strings" "time" ) // WorkspaceFileAdapter implements FileAdapter using the online Workspace API. // // # Path Structure (matches online Workspace API) // // Every file operation is scoped by gid (workspace/project identifier): // // POST /ih5/editor/workspace/readFile {"gid":"xx","path":"Output/result.txt"} // POST /ih5/editor/workspace/writeFile {"gid":"xx","path":"Output/result.txt","content":"..."} // // The adapter uses the exact same gid concept as LocalFileAdapter, so the // same workflow JSON and the same gid value work for both local and online runs. // // Authentication is via Cookie header (ih5bearer token), matching the // DocCenter auth pattern used across the platform. type WorkspaceFileAdapter struct { baseURL string // e.g. "https://editor.visuallogic.ai" gid string // workspace / project identifier cookie string // ih5bearer= httpClient *http.Client } // WorkspaceFileConfig holds configuration for WorkspaceFileAdapter. type WorkspaceFileConfig struct { // BaseURL is the platform base URL (e.g. "https://editor.visuallogic.ai"). BaseURL string // GID is the workspace / project identifier. GID string // Cookie is the full cookie header value, e.g. "ih5bearer=". // Read from /Users/ivx/Documents/docs/auth/.doccenter_cookie at runtime. Cookie string // Timeout for HTTP calls (default: 30s). Timeout time.Duration } // NewWorkspaceFileAdapter creates a WorkspaceFileAdapter. func NewWorkspaceFileAdapter(cfg WorkspaceFileConfig) (*WorkspaceFileAdapter, error) { if cfg.BaseURL == "" { return nil, fmt.Errorf("WorkspaceFileAdapter: BaseURL is required") } if cfg.GID == "" { return nil, fmt.Errorf("WorkspaceFileAdapter: GID is required") } if cfg.Cookie == "" { return nil, fmt.Errorf("WorkspaceFileAdapter: Cookie is required") } timeout := cfg.Timeout if timeout == 0 { timeout = 30 * time.Second } return &WorkspaceFileAdapter{ baseURL: strings.TrimSuffix(cfg.BaseURL, "/"), gid: cfg.GID, cookie: cfg.Cookie, httpClient: &http.Client{ Timeout: timeout, }, }, nil } // GID returns the workspace / project identifier. func (a *WorkspaceFileAdapter) GID() string { return a.gid } // ── helpers ────────────────────────────────────────────────────────────────── // normPath strips the leading slash used in workflow JSON "out" keys. // "/Output/result.txt" → "Output/result.txt" func (a *WorkspaceFileAdapter) normPath(path string) string { return strings.TrimLeft(path, "/") } // post sends a POST request to the workspace API and decodes the JSON response. func (a *WorkspaceFileAdapter) post(ctx context.Context, endpoint string, body interface{}, out interface{}) error { bodyBytes, err := json.Marshal(body) if err != nil { return fmt.Errorf("marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/ih5/editor/workspace/"+endpoint, bytes.NewReader(bodyBytes)) if err != nil { return fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Cookie", a.cookie) resp, err := a.httpClient.Do(req) if err != nil { return fmt.Errorf("POST %s: %w", endpoint, err) } defer resp.Body.Close() respBytes, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("POST %s: read response: %w", endpoint, err) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("POST %s: HTTP %d: %s", endpoint, resp.StatusCode, string(respBytes)) } if out != nil { if err := json.Unmarshal(respBytes, out); err != nil { return fmt.Errorf("POST %s: decode response: %w", endpoint, err) } } return nil } // ensureFile creates the file on the server if it does not already exist. // The online API requires createFile before the first writeFile. func (a *WorkspaceFileAdapter) ensureFile(ctx context.Context, path string) error { err := a.post(ctx, "createFile", map[string]string{ "gid": a.gid, "path": path, }, nil) // Ignore "already exists" style errors (the server may return 200 or an // error body; treat any non-network error as "probably already exists"). _ = err // best-effort create; writeFile will catch real failures return nil } // ── FileAdapter implementation ──────────────────────────────────────────────── // Read implements FileAdapter — fetches file content from the workspace API. func (a *WorkspaceFileAdapter) Read(ctx context.Context, path string) ([]byte, error) { p := a.normPath(path) var resp struct { Version int `json:"version"` Status int `json:"status"` Content string `json:"content"` } if err := a.post(ctx, "readFile", map[string]string{ "gid": a.gid, "path": p, }, &resp); err != nil { return nil, fmt.Errorf("WorkspaceFileAdapter.Read %q: %w", path, err) } return []byte(resp.Content), nil } // Write implements FileAdapter — writes content to the workspace API. // The online API is append-by-version (every write creates a new version), // so WriteMode semantics are emulated on the client side where possible. func (a *WorkspaceFileAdapter) Write(ctx context.Context, path string, content []byte, mode WriteMode) error { p := a.normPath(path) switch mode { case WriteModeFailIfExists: exists, err := a.Exists(ctx, path) if err != nil { return fmt.Errorf("WorkspaceFileAdapter.Write(failIfExists) check: %w", err) } if exists { return &FileExistsError{Path: path} } // Fall through to create + write case WriteModeAppend: existing, err := a.Read(ctx, path) if err == nil { content = append(existing, content...) } // If file doesn't exist yet, treat as fresh write case WriteModePrepend: existing, err := a.Read(ctx, path) if err == nil { content = append(content, existing...) } } // Ensure file exists before writing (online API requires createFile first) _ = a.ensureFile(ctx, p) var writeResp struct { GID string `json:"gid"` Path string `json:"path"` Version int `json:"version"` } if err := a.post(ctx, "writeFile", map[string]interface{}{ "gid": a.gid, "path": p, "content": string(content), }, &writeResp); err != nil { return fmt.Errorf("WorkspaceFileAdapter.Write %q: %w", path, err) } return nil } // Exists implements FileAdapter — checks whether the file exists and has been written // (version > 0) in the workspace. func (a *WorkspaceFileAdapter) Exists(ctx context.Context, path string) (bool, error) { p := a.normPath(path) var resp struct { Files []struct { Path string `json:"path"` Version int `json:"version"` } `json:"files"` } if err := a.post(ctx, "listFile", map[string]string{ "gid": a.gid, }, &resp); err != nil { return false, fmt.Errorf("WorkspaceFileAdapter.Exists: %w", err) } for _, f := range resp.Files { if f.Path == p && f.Version > 0 { return true, nil } } return false, nil } // List implements FileAdapter — returns all file paths in the workspace that // match the given glob pattern. Pattern uses the same convention as // LocalFileAdapter (e.g., "Output/*", "**/*.txt"). Leading slash is stripped. // Returned paths have a leading slash to match workflow convention. func (a *WorkspaceFileAdapter) List(ctx context.Context, pattern string) ([]string, error) { var resp struct { Files []struct { Path string `json:"path"` Version int `json:"version"` } `json:"files"` } if err := a.post(ctx, "listFile", map[string]string{ "gid": a.gid, }, &resp); err != nil { return nil, fmt.Errorf("WorkspaceFileAdapter.List: %w", err) } // Convert pattern to a simple prefix/glob matcher cleanPat := strings.TrimLeft(pattern, "/") var result []string for _, f := range resp.Files { if f.Version == 0 { continue // file created but never written — skip } matched, err := matchGlob(cleanPat, f.Path) if err != nil || !matched { continue } result = append(result, "/"+f.Path) } return result, nil } // matchGlob checks whether path matches the given glob pattern. // Uses the same registry.files glob convention as the workflow engine. func matchGlob(pattern, path string) (bool, error) { // filepath.Match handles simple "*" and "?" wildcards. // For "**" (double-star) patterns, fall back to prefix match. if strings.Contains(pattern, "**") { prefix := strings.SplitN(pattern, "**", 2)[0] return strings.HasPrefix(path, prefix), nil } return filepath.Match(pattern, path) }