You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

330 line
8.8KB

  1. package buildsvc
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "strconv"
  8. "strings"
  9. "sync"
  10. "time"
  11. "qctextbuilder/internal/domain"
  12. "qctextbuilder/internal/mapping"
  13. "qctextbuilder/internal/qcclient"
  14. "qctextbuilder/internal/store"
  15. "qctextbuilder/internal/validation"
  16. )
  17. type StartBuildRequest struct {
  18. TemplateID int64 `json:"templateId"`
  19. RequestName string `json:"requestName"`
  20. GlobalData map[string]any `json:"globalData"`
  21. FieldValues map[string]string `json:"fieldValues"`
  22. }
  23. type BuildResult struct {
  24. BuildID string `json:"buildId"`
  25. QCJobID int64 `json:"qcJobId"`
  26. Status string `json:"status"`
  27. }
  28. type Service interface {
  29. StartBuild(ctx context.Context, req StartBuildRequest) (*BuildResult, error)
  30. PollOnce(ctx context.Context, buildID string) error
  31. FetchEditorURL(ctx context.Context, buildID string) error
  32. GetBuild(ctx context.Context, buildID string) (*domain.SiteBuild, error)
  33. }
  34. type BuildService struct {
  35. qc qcclient.Client
  36. templateStore store.TemplateStore
  37. manifestStore store.ManifestStore
  38. buildStore store.BuildStore
  39. mapping mapping.Service
  40. pollTimeout time.Duration
  41. mu sync.Mutex
  42. inFlightPolls map[string]struct{}
  43. }
  44. func New(qc qcclient.Client, templateStore store.TemplateStore, manifestStore store.ManifestStore, buildStore store.BuildStore, mappingSvc mapping.Service, pollTimeout time.Duration) *BuildService {
  45. if pollTimeout <= 0 {
  46. pollTimeout = 5 * time.Minute
  47. }
  48. return &BuildService{
  49. qc: qc,
  50. templateStore: templateStore,
  51. manifestStore: manifestStore,
  52. buildStore: buildStore,
  53. mapping: mappingSvc,
  54. pollTimeout: pollTimeout,
  55. inFlightPolls: make(map[string]struct{}),
  56. }
  57. }
  58. func (s *BuildService) StartBuild(ctx context.Context, req StartBuildRequest) (*BuildResult, error) {
  59. template, err := s.templateStore.GetTemplateByID(ctx, req.TemplateID)
  60. if err != nil {
  61. return nil, fmt.Errorf("get template: %w", err)
  62. }
  63. if !template.IsAITemplate {
  64. return nil, errors.New("only ai templates are allowed")
  65. }
  66. manifest, err := s.manifestStore.GetActiveManifestByTemplateID(ctx, req.TemplateID)
  67. if err != nil {
  68. return nil, fmt.Errorf("get active manifest: %w", err)
  69. }
  70. if !isBuildAllowed(template.ManifestStatus) {
  71. return nil, fmt.Errorf("template manifest status must be reviewed or validated, got %q", template.ManifestStatus)
  72. }
  73. filteredGlobalData := FilterGlobalData(req.GlobalData)
  74. if err := validation.ValidateBuildGlobalData(filteredGlobalData); err != nil {
  75. return nil, err
  76. }
  77. fields, err := s.manifestStore.ListFieldsByManifestID(ctx, manifest.ID)
  78. if err != nil {
  79. return nil, fmt.Errorf("list manifest fields: %w", err)
  80. }
  81. aiData, err := s.mapping.AssembleAIData(fields, req.FieldValues)
  82. if err != nil {
  83. return nil, fmt.Errorf("assemble aiData: %w", err)
  84. }
  85. if len(aiData) == 0 {
  86. return nil, errors.New("at least one enabled text field value is required")
  87. }
  88. qcReq := qcclient.CreateSiteRequest{
  89. TemplateID: req.TemplateID,
  90. GlobalData: filteredGlobalData,
  91. Content: qcclient.CreateSiteContent{
  92. AIData: aiData,
  93. },
  94. }
  95. requestName := strings.TrimSpace(req.RequestName)
  96. if requestName == "" {
  97. requestName = "build-" + strconv.FormatInt(time.Now().Unix(), 10)
  98. }
  99. buildID := strconv.FormatInt(time.Now().UnixNano(), 10)
  100. globalJSON, _ := json.Marshal(filteredGlobalData)
  101. aiDataJSON, _ := json.Marshal(aiData)
  102. finalPayload, _ := json.Marshal(qcReq)
  103. now := time.Now().UTC()
  104. build := domain.SiteBuild{
  105. ID: buildID,
  106. TemplateID: req.TemplateID,
  107. ManifestID: manifest.ID,
  108. RequestName: requestName,
  109. GlobalDataJSON: globalJSON,
  110. AIDataJSON: aiDataJSON,
  111. FinalSitesPayload: finalPayload,
  112. QCStatus: "draft",
  113. }
  114. if err := s.buildStore.CreateBuild(ctx, build); err != nil {
  115. return nil, fmt.Errorf("create build: %w", err)
  116. }
  117. siteResp, siteRaw, err := s.qc.CreateSite(ctx, qcReq)
  118. if err != nil {
  119. _ = s.buildStore.UpdateBuildFromJob(ctx, buildID, "failed", nil, "", nil, wrapErrorRaw(err), &now)
  120. return nil, fmt.Errorf("post /sites: %w", err)
  121. }
  122. if err := s.buildStore.MarkBuildSubmitted(ctx, buildID, siteResp.JobID, normalizeQCStatus(siteResp.Status), siteRaw, now); err != nil {
  123. return nil, fmt.Errorf("save site submission: %w", err)
  124. }
  125. return &BuildResult{
  126. BuildID: buildID,
  127. QCJobID: siteResp.JobID,
  128. Status: normalizeQCStatus(siteResp.Status),
  129. }, nil
  130. }
  131. func (s *BuildService) PollOnce(ctx context.Context, buildID string) error {
  132. if !s.acquirePollLease(buildID) {
  133. return nil
  134. }
  135. defer s.releasePollLease(buildID)
  136. build, err := s.buildStore.GetBuildByID(ctx, buildID)
  137. if err != nil {
  138. return fmt.Errorf("get build: %w", err)
  139. }
  140. if isTerminalStatus(build.QCStatus) {
  141. return nil
  142. }
  143. if build.QCJobID == nil {
  144. return errors.New("build has no qcJobId")
  145. }
  146. now := time.Now().UTC()
  147. if hasExceededTimeout(build, now, s.pollTimeout) {
  148. return s.buildStore.UpdateBuildFromJob(
  149. ctx,
  150. buildID,
  151. "timeout",
  152. build.QCSiteID,
  153. preservePreviewURL(build.QCPreviewURL, ""),
  154. nil,
  155. wrapErrorMessage("poll timeout exceeded"),
  156. &now,
  157. )
  158. }
  159. job, jobRaw, err := s.qc.GetJob(ctx, *build.QCJobID)
  160. now = time.Now().UTC()
  161. if err != nil {
  162. return s.buildStore.UpdateBuildFromJob(ctx, buildID, "failed", nil, "", nil, wrapErrorRaw(err), &now)
  163. }
  164. status := normalizeQCStatus(job.Status)
  165. if isTerminalStatus(build.QCStatus) {
  166. status = build.QCStatus
  167. }
  168. var finishedAt *time.Time
  169. var siteID *int64
  170. previewURL := strings.TrimSpace(job.Result.PreviewURL)
  171. if previewURL == "" {
  172. previewURL = build.QCPreviewURL
  173. }
  174. if job.Result.SiteID > 0 {
  175. id := job.Result.SiteID
  176. siteID = &id
  177. } else if build.QCSiteID != nil {
  178. siteID = build.QCSiteID
  179. }
  180. if isTerminalStatus(status) {
  181. finishedAt = &now
  182. }
  183. if err := s.buildStore.UpdateBuildFromJob(ctx, buildID, status, siteID, previewURL, jobRaw, nil, finishedAt); err != nil {
  184. return err
  185. }
  186. if status == "done" && siteID != nil && strings.TrimSpace(build.QCEditorURL) == "" {
  187. _ = s.fetchAndStoreEditorURL(ctx, buildID, *siteID)
  188. }
  189. return nil
  190. }
  191. func (s *BuildService) FetchEditorURL(ctx context.Context, buildID string) error {
  192. build, err := s.buildStore.GetBuildByID(ctx, buildID)
  193. if err != nil {
  194. return fmt.Errorf("get build: %w", err)
  195. }
  196. if !isTerminalStatus(build.QCStatus) {
  197. return fmt.Errorf("editor url can only be fetched for terminal build status, got %q", build.QCStatus)
  198. }
  199. if build.QCSiteID == nil {
  200. return errors.New("build has no qcSiteId")
  201. }
  202. if strings.TrimSpace(build.QCEditorURL) != "" {
  203. return nil
  204. }
  205. return s.fetchAndStoreEditorURL(ctx, buildID, *build.QCSiteID)
  206. }
  207. func (s *BuildService) GetBuild(ctx context.Context, buildID string) (*domain.SiteBuild, error) {
  208. return s.buildStore.GetBuildByID(ctx, buildID)
  209. }
  210. func isBuildAllowed(status string) bool {
  211. switch strings.ToLower(strings.TrimSpace(status)) {
  212. case "reviewed", "validated":
  213. return true
  214. default:
  215. return false
  216. }
  217. }
  218. func normalizeQCStatus(status string) string {
  219. s := strings.ToLower(strings.TrimSpace(status))
  220. switch s {
  221. case "", "unknown":
  222. return "queued"
  223. case "in_progress", "running":
  224. return "processing"
  225. case "success", "succeeded", "completed":
  226. return "done"
  227. case "error":
  228. return "failed"
  229. case "queued", "processing", "done", "failed", "timeout":
  230. return s
  231. default:
  232. return "processing"
  233. }
  234. }
  235. func wrapErrorRaw(err error) json.RawMessage {
  236. return wrapErrorMessage(err.Error())
  237. }
  238. func wrapErrorMessage(msg string) json.RawMessage {
  239. payload, marshalErr := json.Marshal(map[string]any{"error": msg})
  240. if marshalErr != nil {
  241. return nil
  242. }
  243. return payload
  244. }
  245. func preservePreviewURL(existing, next string) string {
  246. if strings.TrimSpace(next) != "" {
  247. return next
  248. }
  249. return existing
  250. }
  251. func isTerminalStatus(status string) bool {
  252. switch strings.ToLower(strings.TrimSpace(status)) {
  253. case "done", "failed", "timeout":
  254. return true
  255. default:
  256. return false
  257. }
  258. }
  259. func hasExceededTimeout(build *domain.SiteBuild, now time.Time, pollTimeout time.Duration) bool {
  260. if pollTimeout <= 0 || build.StartedAt == nil {
  261. return false
  262. }
  263. if isTerminalStatus(build.QCStatus) {
  264. return false
  265. }
  266. return now.Sub(*build.StartedAt) > pollTimeout
  267. }
  268. func (s *BuildService) fetchAndStoreEditorURL(ctx context.Context, buildID string, siteID int64) error {
  269. editor, raw, err := s.qc.GetEditorURL(ctx, siteID)
  270. if err != nil {
  271. return fmt.Errorf("get editor url: %w", err)
  272. }
  273. loginURL := strings.TrimSpace(editor.LoginURL)
  274. if loginURL == "" {
  275. return errors.New("empty editor login url")
  276. }
  277. return s.buildStore.UpdateBuildEditorURL(ctx, buildID, loginURL, raw)
  278. }
  279. func (s *BuildService) acquirePollLease(buildID string) bool {
  280. s.mu.Lock()
  281. defer s.mu.Unlock()
  282. if _, ok := s.inFlightPolls[buildID]; ok {
  283. return false
  284. }
  285. s.inFlightPolls[buildID] = struct{}{}
  286. return true
  287. }
  288. func (s *BuildService) releasePollLease(buildID string) {
  289. s.mu.Lock()
  290. defer s.mu.Unlock()
  291. delete(s.inFlightPolls, buildID)
  292. }